Spaces:
Running
Running
Ryan Christian D. Deniega commited on
Commit Β·
b1c84b5
1
Parent(s): b1a8265
fix: cold start 502, favicon, verify state persistence
Browse files- Dockerfile: pre-download HuggingFace models to eliminate cold start 502s
- scoring/engine.py: cache NLP instances per-process (_nlp_cache)
- Cloud Run: set min-instances=1 in deploy.sh
- frontend: Lucide Radar icon favicon (matches navbar)
- frontend/VerifyPage: sessionStorage persistence + Verify Again button
- .gitignore: exclude ml/models/xlmr_model/ and *.safetensors
This view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +47 -0
- .firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache +5 -0
- .firebaserc +13 -3
- .gcloudignore +50 -0
- .gitignore +11 -1
- Dockerfile +67 -0
- api/routes/history.py +111 -6
- api/routes/preview.py +179 -0
- api/routes/trends.py +64 -4
- api/routes/verify.py +4 -0
- api/schemas.py +15 -0
- deploy.sh +78 -0
- evidence/domain_credibility.py +150 -0
- evidence/news_fetcher.py +236 -21
- evidence/similarity.py +80 -0
- evidence/stance_detector.py +194 -0
- extension/background.js +171 -0
- extension/content.css +190 -0
- extension/content.js +390 -0
- extension/generate_icons.py +61 -0
- extension/icons/icon128.png +0 -0
- extension/icons/icon16.png +0 -0
- extension/icons/icon32.png +0 -0
- extension/icons/icon48.png +0 -0
- extension/manifest.json +55 -0
- extension/popup.html +446 -0
- extension/popup.js +238 -0
- firebase.json +0 -1
- firebase_client.py +24 -4
- firestore.indexes.json +11 -49
- frontend/index.html +6 -2
- frontend/package-lock.json +15 -0
- frontend/package.json +3 -1
- frontend/public/logo.svg +13 -0
- frontend/src/App.jsx +33 -2
- frontend/src/api.js +23 -4
- frontend/src/api.ts +84 -0
- frontend/src/components/Navbar.jsx +59 -44
- frontend/src/components/SkeletonCard.jsx +46 -0
- frontend/src/components/WordHighlighter.jsx +115 -0
- frontend/src/firebase.js +19 -5
- frontend/src/index.css +28 -1
- frontend/src/pages/HistoryPage.jsx +530 -64
- frontend/src/pages/TrendsPage.jsx +187 -63
- frontend/src/pages/VerifyPage.jsx +553 -80
- frontend/src/types.ts +133 -0
- frontend/tsconfig.json +39 -0
- frontend/vite.config.js +1 -1
- inputs/url_scraper.py +265 -27
- main.py +13 -4
.dockerignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
**/__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
*.pyo
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.cache/
|
| 8 |
+
venv/
|
| 9 |
+
.venv/
|
| 10 |
+
*.egg-info/
|
| 11 |
+
|
| 12 |
+
# Environment & secrets
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
serviceAccountKey.json
|
| 16 |
+
|
| 17 |
+
# Local data / logs
|
| 18 |
+
data/history.json
|
| 19 |
+
inputs/*.log
|
| 20 |
+
inputs/__pycache__/
|
| 21 |
+
|
| 22 |
+
# ML training artefacts (keep ml/models/ β needed at runtime)
|
| 23 |
+
ml/data/
|
| 24 |
+
ml/*.log
|
| 25 |
+
|
| 26 |
+
# Frontend source (only dist goes to Firebase Hosting, not Cloud Run)
|
| 27 |
+
frontend/
|
| 28 |
+
|
| 29 |
+
# Git / editor
|
| 30 |
+
.git/
|
| 31 |
+
.gitignore
|
| 32 |
+
.gitattributes
|
| 33 |
+
.vscode/
|
| 34 |
+
.idea/
|
| 35 |
+
|
| 36 |
+
# Docs
|
| 37 |
+
docs/
|
| 38 |
+
*.md
|
| 39 |
+
README*
|
| 40 |
+
|
| 41 |
+
# Tests
|
| 42 |
+
tests/
|
| 43 |
+
pytest.ini
|
| 44 |
+
|
| 45 |
+
# Docker itself
|
| 46 |
+
Dockerfile
|
| 47 |
+
.dockerignore
|
.firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache
ADDED
|
@@ -0,0 +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
|
.firebaserc
CHANGED
|
@@ -1,5 +1,15 @@
|
|
| 1 |
{
|
| 2 |
-
"projects": {
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"etags": {}
|
| 5 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"projects": {
|
| 3 |
+
"default": "philverify"
|
| 4 |
+
},
|
| 5 |
+
"targets": {
|
| 6 |
+
"philverify": {
|
| 7 |
+
"hosting": {
|
| 8 |
+
"philverify": [
|
| 9 |
+
"philverify"
|
| 10 |
+
]
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
},
|
| 14 |
"etags": {}
|
| 15 |
+
}
|
.gcloudignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .gcloudignore β Cloud Build source upload exclusions
|
| 2 |
+
# gcloud builds submit uses this before creating the source tarball.
|
| 3 |
+
# Patterns follow .gitignore syntax.
|
| 4 |
+
|
| 5 |
+
# ββ Heavy runtimes / caches βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
venv/
|
| 7 |
+
.venv/
|
| 8 |
+
__pycache__/
|
| 9 |
+
**/__pycache__/
|
| 10 |
+
*.py[cod]
|
| 11 |
+
.cache/
|
| 12 |
+
.pytest_cache/
|
| 13 |
+
|
| 14 |
+
# ββ Secrets (never upload) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
.env
|
| 16 |
+
.env.*
|
| 17 |
+
serviceAccountKey.json
|
| 18 |
+
*.json.key
|
| 19 |
+
|
| 20 |
+
# ββ ML artefacts (large β Docker downloads from HuggingFace at build time) βββ
|
| 21 |
+
ml/models/
|
| 22 |
+
ml/data/raw/
|
| 23 |
+
ml/data/processed/
|
| 24 |
+
ml/data/combined/
|
| 25 |
+
|
| 26 |
+
# ββ Frontend source & deps (built separately, not needed in Cloud Run) βββββββ
|
| 27 |
+
frontend/node_modules/
|
| 28 |
+
frontend/dist/
|
| 29 |
+
|
| 30 |
+
# ββ Dataset pipeline scripts (not needed at runtime) βββββββββββββββββββββββββ
|
| 31 |
+
ml/data_sources/
|
| 32 |
+
ml/train_*.py
|
| 33 |
+
ml/dataset_builder.py
|
| 34 |
+
ml/combined_dataset.py
|
| 35 |
+
ml/_smoke_test.py
|
| 36 |
+
|
| 37 |
+
# ββ Tests & docs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
tests/
|
| 39 |
+
docs/
|
| 40 |
+
|
| 41 |
+
# ββ OS / editor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
.DS_Store
|
| 43 |
+
.vscode/
|
| 44 |
+
.idea/
|
| 45 |
+
*.swp
|
| 46 |
+
|
| 47 |
+
# ββ Git βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
.git/
|
| 49 |
+
.gitignore
|
| 50 |
+
.gitattributes
|
.gitignore
CHANGED
|
@@ -22,10 +22,20 @@ build/
|
|
| 22 |
# OS
|
| 23 |
.DS_Store
|
| 24 |
|
| 25 |
-
# ML models (too large for git)
|
| 26 |
ml/models/*.pkl
|
| 27 |
ml/models/*.bin
|
| 28 |
ml/models/*.pt
|
|
|
|
|
|
|
| 29 |
serviceAccountKey.json
|
| 30 |
*.json.key
|
| 31 |
docs/*.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# OS
|
| 23 |
.DS_Store
|
| 24 |
|
| 25 |
+
# ML models (too large for git β use DVC or download separately)
|
| 26 |
ml/models/*.pkl
|
| 27 |
ml/models/*.bin
|
| 28 |
ml/models/*.pt
|
| 29 |
+
ml/models/*.safetensors
|
| 30 |
+
ml/models/xlmr_model/
|
| 31 |
serviceAccountKey.json
|
| 32 |
*.json.key
|
| 33 |
docs/*.json
|
| 34 |
+
|
| 35 |
+
# Dataset pipeline β raw downloads & processed parquet (regenerate via dataset_builder.py)
|
| 36 |
+
ml/data/raw/
|
| 37 |
+
ml/data/processed/
|
| 38 |
+
ml/_smoke_test.py
|
| 39 |
+
|
| 40 |
+
# Local history persistence (user data β do not commit)
|
| 41 |
+
data/history.json
|
Dockerfile
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ PhilVerify API β Cloud Run Dockerfile βββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
# Build: docker build -t philverify-api .
|
| 3 |
+
# Run: docker run -p 8080:8080 --env-file .env philverify-api
|
| 4 |
+
|
| 5 |
+
FROM python:3.12-slim
|
| 6 |
+
|
| 7 |
+
# ββ System dependencies βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
# tesseract: OCR for image verification
|
| 9 |
+
# ffmpeg: audio decoding for Whisper (video/audio input)
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
tesseract-ocr \
|
| 12 |
+
tesseract-ocr-fil \
|
| 13 |
+
tesseract-ocr-eng \
|
| 14 |
+
ffmpeg \
|
| 15 |
+
libgl1 \
|
| 16 |
+
libglib2.0-0 \
|
| 17 |
+
curl \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# ββ Python dependencies βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
# Upgrade pip + add setuptools (required by openai-whisper's setup.py on 3.12-slim)
|
| 24 |
+
COPY requirements.txt .
|
| 25 |
+
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
| 26 |
+
pip install --no-cache-dir -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Download spaCy English model (small, ~12 MB)
|
| 29 |
+
RUN python -m spacy download en_core_web_sm || true
|
| 30 |
+
|
| 31 |
+
# Download NLTK data used by the NLP pipeline
|
| 32 |
+
RUN python -c "import nltk; nltk.download('punkt', quiet=True); nltk.download('stopwords', quiet=True); nltk.download('punkt_tab', quiet=True)" || true
|
| 33 |
+
|
| 34 |
+
# ββ Application code ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
COPY . .
|
| 36 |
+
|
| 37 |
+
# Remove local secrets β Cloud Run uses its own service account (ADC)
|
| 38 |
+
# The serviceAccountKey.json is NOT needed inside the container.
|
| 39 |
+
RUN rm -f serviceAccountKey.json .env
|
| 40 |
+
|
| 41 |
+
# Pre-download Whisper base model so cold starts are faster
|
| 42 |
+
RUN python -c "import whisper; whisper.load_model('base')" || true
|
| 43 |
+
|
| 44 |
+
# Pre-download HuggingFace transformer models used by the NLP pipeline so that
|
| 45 |
+
# cold starts don't hit the network β these would otherwise be fetched on the
|
| 46 |
+
# first /verify request and cause a Firebase Hosting 502 timeout (~1.2 GB total).
|
| 47 |
+
RUN python -c "\
|
| 48 |
+
from transformers import pipeline; \
|
| 49 |
+
print('Downloading twitter-roberta-base-sentiment...'); \
|
| 50 |
+
pipeline('text-classification', model='cardiffnlp/twitter-roberta-base-sentiment-latest'); \
|
| 51 |
+
print('Downloading emotion-english-distilroberta...'); \
|
| 52 |
+
pipeline('text-classification', model='j-hartmann/emotion-english-distilroberta-base'); \
|
| 53 |
+
print('Downloading distilbart-cnn-6-6 (claim extractor)...'); \
|
| 54 |
+
pipeline('summarization', model='sshleifer/distilbart-cnn-6-6'); \
|
| 55 |
+
print('All HuggingFace models cached.'); \
|
| 56 |
+
" || true
|
| 57 |
+
|
| 58 |
+
# ββ Runtime βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
# Cloud Run sets PORT automatically; default to 8080 for local runs.
|
| 60 |
+
ENV PORT=8080
|
| 61 |
+
ENV APP_ENV=production
|
| 62 |
+
ENV DEBUG=false
|
| 63 |
+
|
| 64 |
+
EXPOSE 8080
|
| 65 |
+
|
| 66 |
+
# Use exec form so signals (SIGTERM) reach uvicorn directly
|
| 67 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT} --workers 1 --timeout-keep-alive 75"]
|
api/routes/history.py
CHANGED
|
@@ -1,21 +1,101 @@
|
|
| 1 |
"""
|
| 2 |
PhilVerify β History Route
|
| 3 |
GET /history β Returns past verification logs with pagination.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
|
|
|
| 5 |
import logging
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
from api.schemas import HistoryResponse, HistoryEntry, Verdict
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
router = APIRouter(prefix="/history", tags=["History"])
|
| 11 |
|
| 12 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
_HISTORY: list[dict] = []
|
| 14 |
|
| 15 |
|
| 16 |
def record_verification(entry: dict) -> None:
|
| 17 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
_HISTORY.append(entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
@router.get(
|
|
@@ -31,7 +111,7 @@ async def get_history(
|
|
| 31 |
) -> HistoryResponse:
|
| 32 |
logger.info("GET /history | page=%d limit=%d", page, limit)
|
| 33 |
|
| 34 |
-
#
|
| 35 |
try:
|
| 36 |
from firebase_client import get_verifications, get_verification_count
|
| 37 |
vf = verdict_filter.value if verdict_filter else None
|
|
@@ -55,9 +135,34 @@ async def get_history(
|
|
| 55 |
],
|
| 56 |
)
|
| 57 |
except Exception as e:
|
| 58 |
-
logger.debug("Firestore history
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
# In-memory
|
| 61 |
entries = list(reversed(_HISTORY))
|
| 62 |
if verdict_filter:
|
| 63 |
entries = [e for e in entries if e.get("verdict") == verdict_filter.value]
|
|
|
|
| 1 |
"""
|
| 2 |
PhilVerify β History Route
|
| 3 |
GET /history β Returns past verification logs with pagination.
|
| 4 |
+
|
| 5 |
+
Persistence tier order (best to worst):
|
| 6 |
+
1. Firestore β requires Cloud Firestore API to be enabled in GCP console
|
| 7 |
+
2. Local JSON file β data/history.json, survives server restarts, no setup needed
|
| 8 |
+
3. In-memory list β last resort, resets on every restart
|
| 9 |
"""
|
| 10 |
+
import json
|
| 11 |
import logging
|
| 12 |
+
import threading
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from fastapi import APIRouter, Query, HTTPException
|
| 15 |
from api.schemas import HistoryResponse, HistoryEntry, Verdict
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
router = APIRouter(prefix="/history", tags=["History"])
|
| 19 |
|
| 20 |
+
# ββ Local JSON file store βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
# Survives server restarts. Used when Firestore is unavailable (e.g. API disabled).
|
| 22 |
+
_HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "history.json"
|
| 23 |
+
_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
_file_lock = threading.Lock() # Guard concurrent writes
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _load_history_file() -> list[dict]:
|
| 28 |
+
"""Read all records from the local JSON history file."""
|
| 29 |
+
try:
|
| 30 |
+
if _HISTORY_FILE.exists():
|
| 31 |
+
return json.loads(_HISTORY_FILE.read_text(encoding="utf-8"))
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.warning("Could not read history file: %s", e)
|
| 34 |
+
return []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _append_history_file(entry: dict) -> None:
|
| 38 |
+
"""Atomically append one entry to the local JSON history file."""
|
| 39 |
+
with _file_lock:
|
| 40 |
+
records = _load_history_file()
|
| 41 |
+
records.append(entry)
|
| 42 |
+
try:
|
| 43 |
+
_HISTORY_FILE.write_text(
|
| 44 |
+
json.dumps(records, ensure_ascii=False, indent=2),
|
| 45 |
+
encoding="utf-8",
|
| 46 |
+
)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.warning("Could not write history file: %s", e)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# In-memory fallback (last resort β loses data on restart)
|
| 52 |
_HISTORY: list[dict] = []
|
| 53 |
|
| 54 |
|
| 55 |
def record_verification(entry: dict) -> None:
|
| 56 |
+
"""
|
| 57 |
+
Called by the scoring engine after every verification.
|
| 58 |
+
Writes to the local JSON file so history persists even without Firestore.
|
| 59 |
+
Also keeps the in-memory list in sync for the current process lifetime.
|
| 60 |
+
"""
|
| 61 |
_HISTORY.append(entry)
|
| 62 |
+
_append_history_file(entry)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get(
|
| 66 |
+
"/{entry_id}",
|
| 67 |
+
summary="Get single verification by ID",
|
| 68 |
+
description="Returns the full raw record for a single verification, including layer scores, entities, sentiment.",
|
| 69 |
+
)
|
| 70 |
+
async def get_history_entry(entry_id: str) -> dict:
|
| 71 |
+
logger.info("GET /history/%s", entry_id)
|
| 72 |
+
|
| 73 |
+
# Tier 1: Firestore
|
| 74 |
+
try:
|
| 75 |
+
from firebase_client import get_firestore
|
| 76 |
+
db = get_firestore()
|
| 77 |
+
if db:
|
| 78 |
+
doc = db.collection("verifications").document(entry_id).get()
|
| 79 |
+
if doc.exists:
|
| 80 |
+
return doc.to_dict()
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.debug("Firestore detail unavailable (%s) β trying local file", e)
|
| 83 |
+
|
| 84 |
+
# Tier 2: Local JSON file
|
| 85 |
+
try:
|
| 86 |
+
records = _load_history_file()
|
| 87 |
+
for r in records:
|
| 88 |
+
if r.get("id") == entry_id:
|
| 89 |
+
return r
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
# Tier 3: In-memory
|
| 94 |
+
for r in _HISTORY:
|
| 95 |
+
if r.get("id") == entry_id:
|
| 96 |
+
return r
|
| 97 |
+
|
| 98 |
+
raise HTTPException(status_code=404, detail="Verification not found")
|
| 99 |
|
| 100 |
|
| 101 |
@router.get(
|
|
|
|
| 111 |
) -> HistoryResponse:
|
| 112 |
logger.info("GET /history | page=%d limit=%d", page, limit)
|
| 113 |
|
| 114 |
+
# ββ Tier 1: Firestore βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
try:
|
| 116 |
from firebase_client import get_verifications, get_verification_count
|
| 117 |
vf = verdict_filter.value if verdict_filter else None
|
|
|
|
| 135 |
],
|
| 136 |
)
|
| 137 |
except Exception as e:
|
| 138 |
+
logger.debug("Firestore history unavailable (%s) β trying local file", e)
|
| 139 |
+
|
| 140 |
+
# ββ Tier 2: Local JSON file βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
# Load from file rather than in-memory list so data survives restarts.
|
| 142 |
+
file_entries = list(reversed(_load_history_file()))
|
| 143 |
+
if file_entries:
|
| 144 |
+
if verdict_filter:
|
| 145 |
+
file_entries = [e for e in file_entries if e.get("verdict") == verdict_filter.value]
|
| 146 |
+
total = len(file_entries)
|
| 147 |
+
start = (page - 1) * limit
|
| 148 |
+
paginated = file_entries[start : start + limit]
|
| 149 |
+
return HistoryResponse(
|
| 150 |
+
total=total,
|
| 151 |
+
entries=[
|
| 152 |
+
HistoryEntry(
|
| 153 |
+
id=e["id"],
|
| 154 |
+
timestamp=e["timestamp"],
|
| 155 |
+
input_type=e.get("input_type", "text"),
|
| 156 |
+
text_preview=e.get("text_preview", "")[:120],
|
| 157 |
+
verdict=Verdict(e["verdict"]),
|
| 158 |
+
confidence=e["confidence"],
|
| 159 |
+
final_score=e["final_score"],
|
| 160 |
+
)
|
| 161 |
+
for e in paginated
|
| 162 |
+
],
|
| 163 |
+
)
|
| 164 |
|
| 165 |
+
# ββ Tier 3: In-memory (last resort β resets on restart) βββββββββββββββββββ
|
| 166 |
entries = list(reversed(_HISTORY))
|
| 167 |
if verdict_filter:
|
| 168 |
entries = [e for e in entries if e.get("verdict") == verdict_filter.value]
|
api/routes/preview.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhilVerify β URL Preview Route
|
| 3 |
+
GET /preview?url=<encoded_url>
|
| 4 |
+
|
| 5 |
+
Fetches Open Graph / meta tags from the given URL and returns a lightweight
|
| 6 |
+
article card payload: title, description, image, site name, favicon, and domain.
|
| 7 |
+
Used by the frontend to show a "link unfurl" preview before/after verification.
|
| 8 |
+
"""
|
| 9 |
+
import logging
|
| 10 |
+
import re
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Query, HTTPException
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
router = APIRouter(prefix="/preview", tags=["Preview"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class URLPreview(BaseModel):
|
| 22 |
+
title: Optional[str] = None
|
| 23 |
+
description: Optional[str] = None
|
| 24 |
+
image: Optional[str] = None
|
| 25 |
+
site_name: Optional[str] = None
|
| 26 |
+
favicon: Optional[str] = None
|
| 27 |
+
domain: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _slug_to_title(url: str) -> Optional[str]:
|
| 31 |
+
"""Convert URL path slug to a readable title.
|
| 32 |
+
e.g. 'remulla-chides-bulacan-guv-for-alleged-road-abuse-dont-act-like-a-king' β
|
| 33 |
+
'Remulla Chides Bulacan Guv For Alleged Road Abuse Dont Act Like A King'
|
| 34 |
+
"""
|
| 35 |
+
parsed = urlparse(url)
|
| 36 |
+
segments = [s for s in parsed.path.split("/") if s and not s.isdigit() and len(s) > 4]
|
| 37 |
+
if segments:
|
| 38 |
+
slug = segments[-1]
|
| 39 |
+
# Remove common file extensions
|
| 40 |
+
slug = re.sub(r'\.(html?|php|aspx?)$', '', slug, flags=re.IGNORECASE)
|
| 41 |
+
# Strip UTM / query artifacts that leaked into path
|
| 42 |
+
slug = slug.split('?')[0]
|
| 43 |
+
return ' '.join(w.capitalize() for w in slug.replace('-', ' ').replace('_', ' ').split())
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _extract_preview(html: str, base_url: str, original_url: str = "") -> URLPreview:
|
| 48 |
+
"""Parse OG / meta tags from raw HTML."""
|
| 49 |
+
from bs4 import BeautifulSoup
|
| 50 |
+
|
| 51 |
+
parsed_base = urlparse(base_url)
|
| 52 |
+
domain = parsed_base.netloc.replace("www.", "")
|
| 53 |
+
origin = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
| 54 |
+
|
| 55 |
+
# Parse head first for speed, then fall back to full doc if needed
|
| 56 |
+
head_end = html.find("</head>")
|
| 57 |
+
head_html = html[:head_end + 7] if head_end != -1 else html[:8000]
|
| 58 |
+
soup_head = BeautifulSoup(head_html, "lxml")
|
| 59 |
+
# Also keep full soup for body-level og: tags some CDNs inject
|
| 60 |
+
soup_full = BeautifulSoup(html[:60_000], "lxml") if head_end == -1 or head_end > 60_000 else soup_head
|
| 61 |
+
|
| 62 |
+
def meta(soup, prop=None, name=None):
|
| 63 |
+
if prop:
|
| 64 |
+
el = soup.find("meta", property=prop) or soup.find("meta", attrs={"property": prop})
|
| 65 |
+
else:
|
| 66 |
+
el = soup.find("meta", attrs={"name": name})
|
| 67 |
+
return (el.get("content") or "").strip() if el else None
|
| 68 |
+
|
| 69 |
+
def m(prop=None, name=None):
|
| 70 |
+
return meta(soup_head, prop=prop, name=name) or meta(soup_full, prop=prop, name=name)
|
| 71 |
+
|
| 72 |
+
title = (
|
| 73 |
+
m(prop="og:title")
|
| 74 |
+
or m(name="twitter:title")
|
| 75 |
+
or (soup_head.title.get_text(strip=True) if soup_head.title else None)
|
| 76 |
+
or _slug_to_title(original_url or base_url)
|
| 77 |
+
)
|
| 78 |
+
description = (
|
| 79 |
+
m(prop="og:description")
|
| 80 |
+
or m(name="twitter:description")
|
| 81 |
+
or m(name="description")
|
| 82 |
+
)
|
| 83 |
+
image = (
|
| 84 |
+
m(prop="og:image")
|
| 85 |
+
or m(name="twitter:image")
|
| 86 |
+
or m(name="twitter:image:src")
|
| 87 |
+
)
|
| 88 |
+
site_name = m(prop="og:site_name") or domain
|
| 89 |
+
|
| 90 |
+
# Resolve relative image URLs
|
| 91 |
+
if image and image.startswith("//"):
|
| 92 |
+
image = f"{parsed_base.scheme}:{image}"
|
| 93 |
+
elif image and image.startswith("/"):
|
| 94 |
+
image = f"{origin}{image}"
|
| 95 |
+
|
| 96 |
+
# Favicon: try link[rel=icon], fallback to /favicon.ico
|
| 97 |
+
favicon = None
|
| 98 |
+
icon_el = (
|
| 99 |
+
soup_head.find("link", rel="icon")
|
| 100 |
+
or soup_head.find("link", rel="shortcut icon")
|
| 101 |
+
or soup_head.find("link", rel=lambda v: v and "icon" in v)
|
| 102 |
+
)
|
| 103 |
+
if icon_el and icon_el.get("href"):
|
| 104 |
+
href = icon_el["href"].strip()
|
| 105 |
+
if href.startswith("//"):
|
| 106 |
+
favicon = f"{parsed_base.scheme}:{href}"
|
| 107 |
+
elif href.startswith("/"):
|
| 108 |
+
favicon = f"{origin}{href}"
|
| 109 |
+
else:
|
| 110 |
+
favicon = href
|
| 111 |
+
else:
|
| 112 |
+
favicon = f"{origin}/favicon.ico"
|
| 113 |
+
|
| 114 |
+
return URLPreview(
|
| 115 |
+
title=title or None,
|
| 116 |
+
description=description or None,
|
| 117 |
+
image=image or None,
|
| 118 |
+
site_name=site_name or None,
|
| 119 |
+
favicon=favicon,
|
| 120 |
+
domain=domain,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
_BOT_TITLES = {
|
| 125 |
+
"just a moment", "attention required", "access denied", "please wait",
|
| 126 |
+
"checking your browser", "ddos-guard", "enable javascript", "403 forbidden",
|
| 127 |
+
"404 not found", "503 service unavailable",
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@router.get("", response_model=URLPreview, summary="Fetch article preview (OG meta)")
|
| 132 |
+
async def get_preview(url: str = Query(..., description="Article URL to preview")) -> URLPreview:
|
| 133 |
+
try:
|
| 134 |
+
import httpx
|
| 135 |
+
except ImportError:
|
| 136 |
+
raise HTTPException(status_code=500, detail="httpx not installed")
|
| 137 |
+
|
| 138 |
+
headers = {
|
| 139 |
+
"User-Agent": (
|
| 140 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
| 141 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 142 |
+
"Chrome/122.0.0.0 Safari/537.36"
|
| 143 |
+
),
|
| 144 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 145 |
+
"Accept-Language": "en-US,en;q=0.5",
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
parsed = urlparse(url)
|
| 149 |
+
domain = parsed.netloc.replace("www.", "")
|
| 150 |
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
| 151 |
+
slug_title = _slug_to_title(url)
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
| 155 |
+
resp = await client.get(url, headers=headers)
|
| 156 |
+
if resp.status_code >= 400:
|
| 157 |
+
logger.warning("Preview fetch returned %d for %s", resp.status_code, url)
|
| 158 |
+
return URLPreview(
|
| 159 |
+
domain=domain,
|
| 160 |
+
site_name=domain,
|
| 161 |
+
title=slug_title,
|
| 162 |
+
favicon=f"{origin}/favicon.ico",
|
| 163 |
+
)
|
| 164 |
+
preview = _extract_preview(resp.text, str(resp.url), original_url=url)
|
| 165 |
+
# If OG parsing returned no title, or got a bot-challenge page title, fall back to slug
|
| 166 |
+
if not preview.title or preview.title.lower().strip() in _BOT_TITLES:
|
| 167 |
+
preview.title = slug_title
|
| 168 |
+
# Don't keep description/image from a bot-challenge page
|
| 169 |
+
preview.description = None
|
| 170 |
+
preview.image = None
|
| 171 |
+
return preview
|
| 172 |
+
except Exception as exc:
|
| 173 |
+
logger.warning("Preview fetch failed for %s: %s", url, exc)
|
| 174 |
+
return URLPreview(
|
| 175 |
+
domain=domain,
|
| 176 |
+
site_name=domain,
|
| 177 |
+
title=slug_title,
|
| 178 |
+
favicon=f"{origin}/favicon.ico",
|
| 179 |
+
)
|
api/routes/trends.py
CHANGED
|
@@ -10,8 +10,33 @@ from api.schemas import TrendsResponse, TrendingEntity, TrendingTopic, Verdict
|
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
router = APIRouter(prefix="/trends", tags=["Trends"])
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
@router.get(
|
|
@@ -26,13 +51,15 @@ async def get_trends(
|
|
| 26 |
) -> TrendsResponse:
|
| 27 |
logger.info("GET /trends | days=%d", days)
|
| 28 |
|
|
|
|
|
|
|
| 29 |
entity_counter: Counter = Counter()
|
| 30 |
entity_type_map: dict[str, str] = {}
|
| 31 |
entity_fake_counter: Counter = Counter()
|
| 32 |
topic_counter: Counter = Counter()
|
| 33 |
topic_verdict_map: dict[str, list[str]] = {}
|
| 34 |
|
| 35 |
-
for entry in
|
| 36 |
is_fake = entry.get("verdict") in (Verdict.LIKELY_FAKE.value, Verdict.UNVERIFIED.value)
|
| 37 |
entities = entry.get("entities", {})
|
| 38 |
|
|
@@ -81,4 +108,37 @@ async def get_trends(
|
|
| 81 |
for topic, count in topic_counter.most_common(limit)
|
| 82 |
]
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
router = APIRouter(prefix="/trends", tags=["Trends"])
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def _load_all_history() -> list[dict]:
|
| 15 |
+
"""
|
| 16 |
+
Return all history records from the best available source:
|
| 17 |
+
1. Firestore 2. Local JSON file 3. In-memory list (fallback)
|
| 18 |
+
"""
|
| 19 |
+
# Tier 1: Firestore
|
| 20 |
+
try:
|
| 21 |
+
from firebase_client import get_all_verifications_sync
|
| 22 |
+
records = get_all_verifications_sync()
|
| 23 |
+
if records:
|
| 24 |
+
return records
|
| 25 |
+
except Exception:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
# Tier 2: Local JSON file (persists across restarts)
|
| 29 |
+
try:
|
| 30 |
+
from api.routes.history import _load_history_file
|
| 31 |
+
records = _load_history_file()
|
| 32 |
+
if records:
|
| 33 |
+
return records
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
# Tier 3: In-memory (empty after restart, but keeps current session data)
|
| 38 |
+
from api.routes.history import _HISTORY
|
| 39 |
+
return list(_HISTORY)
|
| 40 |
|
| 41 |
|
| 42 |
@router.get(
|
|
|
|
| 51 |
) -> TrendsResponse:
|
| 52 |
logger.info("GET /trends | days=%d", days)
|
| 53 |
|
| 54 |
+
all_history = _load_all_history()
|
| 55 |
+
|
| 56 |
entity_counter: Counter = Counter()
|
| 57 |
entity_type_map: dict[str, str] = {}
|
| 58 |
entity_fake_counter: Counter = Counter()
|
| 59 |
topic_counter: Counter = Counter()
|
| 60 |
topic_verdict_map: dict[str, list[str]] = {}
|
| 61 |
|
| 62 |
+
for entry in all_history:
|
| 63 |
is_fake = entry.get("verdict") in (Verdict.LIKELY_FAKE.value, Verdict.UNVERIFIED.value)
|
| 64 |
entities = entry.get("entities", {})
|
| 65 |
|
|
|
|
| 108 |
for topic, count in topic_counter.most_common(limit)
|
| 109 |
]
|
| 110 |
|
| 111 |
+
# ββ Verdict distribution totals βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
verdict_dist: dict[str, int] = {"Credible": 0, "Unverified": 0, "Likely Fake": 0}
|
| 113 |
+
day_map: dict[str, dict[str, int]] = {} # date β {Credible, Unverified, Likely Fake}
|
| 114 |
+
|
| 115 |
+
for entry in all_history:
|
| 116 |
+
v = entry.get("verdict", "Unverified")
|
| 117 |
+
if v in verdict_dist:
|
| 118 |
+
verdict_dist[v] += 1
|
| 119 |
+
|
| 120 |
+
ts = entry.get("timestamp", "")
|
| 121 |
+
date_key = ts[:10] if ts else "" # YYYY-MM-DD prefix
|
| 122 |
+
if date_key:
|
| 123 |
+
bucket = day_map.setdefault(date_key, {"Credible": 0, "Unverified": 0, "Likely Fake": 0})
|
| 124 |
+
if v in bucket:
|
| 125 |
+
bucket[v] += 1
|
| 126 |
+
|
| 127 |
+
from api.schemas import VerdictDayPoint
|
| 128 |
+
verdict_by_day = [
|
| 129 |
+
VerdictDayPoint(
|
| 130 |
+
date=d,
|
| 131 |
+
credible=day_map[d]["Credible"],
|
| 132 |
+
unverified=day_map[d]["Unverified"],
|
| 133 |
+
fake=day_map[d]["Likely Fake"],
|
| 134 |
+
)
|
| 135 |
+
for d in sorted(day_map.keys())
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
return TrendsResponse(
|
| 139 |
+
top_entities=top_entities,
|
| 140 |
+
top_topics=top_topics,
|
| 141 |
+
verdict_distribution=verdict_dist,
|
| 142 |
+
verdict_by_day=verdict_by_day,
|
| 143 |
+
)
|
| 144 |
+
|
api/routes/verify.py
CHANGED
|
@@ -67,6 +67,10 @@ async def verify_url(body: URLVerifyRequest) -> VerificationResponse:
|
|
| 67 |
return result
|
| 68 |
except HTTPException:
|
| 69 |
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
except Exception as exc:
|
| 71 |
logger.exception("verify/url error: %s", exc)
|
| 72 |
raise HTTPException(status_code=500, detail=f"URL verification failed: {exc}") from exc
|
|
|
|
| 67 |
return result
|
| 68 |
except HTTPException:
|
| 69 |
raise
|
| 70 |
+
except ValueError as exc:
|
| 71 |
+
# Expected user-facing errors (e.g. robots.txt block, bad URL)
|
| 72 |
+
logger.warning("verify/url rejected: %s", exc)
|
| 73 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 74 |
except Exception as exc:
|
| 75 |
logger.exception("verify/url error: %s", exc)
|
| 76 |
raise HTTPException(status_code=500, detail=f"URL verification failed: {exc}") from exc
|
api/schemas.py
CHANGED
|
@@ -138,9 +138,24 @@ class TrendingTopic(BaseModel):
|
|
| 138 |
dominant_verdict: Verdict
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
class TrendsResponse(BaseModel):
|
| 142 |
top_entities: list[TrendingEntity]
|
| 143 |
top_topics: list[TrendingTopic]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
|
| 146 |
# ββ Error βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 138 |
dominant_verdict: Verdict
|
| 139 |
|
| 140 |
|
| 141 |
+
class VerdictDayPoint(BaseModel):
|
| 142 |
+
date: str # YYYY-MM-DD
|
| 143 |
+
credible: int = 0
|
| 144 |
+
unverified: int = 0
|
| 145 |
+
fake: int = 0
|
| 146 |
+
|
| 147 |
+
|
| 148 |
class TrendsResponse(BaseModel):
|
| 149 |
top_entities: list[TrendingEntity]
|
| 150 |
top_topics: list[TrendingTopic]
|
| 151 |
+
verdict_distribution: dict[str, int] = Field(
|
| 152 |
+
default_factory=dict,
|
| 153 |
+
description="Counts per verdict: Credible, Unverified, Likely Fake",
|
| 154 |
+
)
|
| 155 |
+
verdict_by_day: list[VerdictDayPoint] = Field(
|
| 156 |
+
default_factory=list,
|
| 157 |
+
description="Day-by-day verdict counts for the area chart (last N days)",
|
| 158 |
+
)
|
| 159 |
|
| 160 |
|
| 161 |
# ββ Error βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
deploy.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ββ PhilVerify β Firebase + Cloud Run Deployment Script βββββββββββββββββββββββ
|
| 3 |
+
# Usage:
|
| 4 |
+
# chmod +x deploy.sh
|
| 5 |
+
# ./deploy.sh YOUR_GCP_PROJECT_ID
|
| 6 |
+
#
|
| 7 |
+
# Prerequisites:
|
| 8 |
+
# brew install google-cloud-sdk firebase-cli
|
| 9 |
+
# gcloud auth login
|
| 10 |
+
# gcloud auth configure-docker
|
| 11 |
+
# firebase login
|
| 12 |
+
|
| 13 |
+
set -euo pipefail
|
| 14 |
+
|
| 15 |
+
PROJECT_ID="${1:-}"
|
| 16 |
+
REGION="asia-southeast1"
|
| 17 |
+
SERVICE_NAME="philverify-api"
|
| 18 |
+
IMAGE="gcr.io/${PROJECT_ID}/${SERVICE_NAME}"
|
| 19 |
+
|
| 20 |
+
if [[ -z "$PROJECT_ID" ]]; then
|
| 21 |
+
echo "Usage: ./deploy.sh YOUR_GCP_PROJECT_ID"
|
| 22 |
+
exit 1
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
echo "βΆ Project: $PROJECT_ID | Region: $REGION | Service: $SERVICE_NAME"
|
| 26 |
+
|
| 27 |
+
# ββ 1. Set GCP project ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
gcloud config set project "$PROJECT_ID"
|
| 29 |
+
|
| 30 |
+
# ββ 2. Build + push Docker image to GCR ββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
echo ""
|
| 32 |
+
echo "βΆ Building & pushing Docker image (this takes ~10 min first time)β¦"
|
| 33 |
+
gcloud builds submit \
|
| 34 |
+
--tag "$IMAGE" \
|
| 35 |
+
--timeout=30m \
|
| 36 |
+
.
|
| 37 |
+
|
| 38 |
+
# ββ 3. Deploy to Cloud Run ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
echo ""
|
| 40 |
+
echo "βΆ Deploying to Cloud Runβ¦"
|
| 41 |
+
gcloud run deploy "$SERVICE_NAME" \
|
| 42 |
+
--image "$IMAGE" \
|
| 43 |
+
--region "$REGION" \
|
| 44 |
+
--platform managed \
|
| 45 |
+
--allow-unauthenticated \
|
| 46 |
+
--memory 4Gi \
|
| 47 |
+
--cpu 2 \
|
| 48 |
+
--concurrency 10 \
|
| 49 |
+
--timeout 300 \
|
| 50 |
+
--min-instances 1 \
|
| 51 |
+
--max-instances 3 \
|
| 52 |
+
--set-env-vars "APP_ENV=production,DEBUG=false,LOG_LEVEL=INFO" \
|
| 53 |
+
--set-env-vars "ALLOWED_ORIGINS=https://${PROJECT_ID}.web.app,https://${PROJECT_ID}.firebaseapp.com"
|
| 54 |
+
# Add secrets like NEWS_API_KEY via:
|
| 55 |
+
# --update-secrets NEWS_API_KEY=philverify-news-api-key:latest
|
| 56 |
+
|
| 57 |
+
# ββ 4. Link Firebase project ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
echo ""
|
| 59 |
+
echo "βΆ Setting Firebase projectβ¦"
|
| 60 |
+
firebase use "$PROJECT_ID"
|
| 61 |
+
|
| 62 |
+
# ββ 5. Build React frontend βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
echo ""
|
| 64 |
+
echo "βΆ Building React frontendβ¦"
|
| 65 |
+
cd frontend
|
| 66 |
+
npm ci
|
| 67 |
+
npm run build
|
| 68 |
+
cd ..
|
| 69 |
+
|
| 70 |
+
# ββ 6. Deploy to Firebase Hosting ββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
echo ""
|
| 72 |
+
echo "βΆ Deploying to Firebase Hostingβ¦"
|
| 73 |
+
firebase deploy --only hosting,firestore
|
| 74 |
+
|
| 75 |
+
echo ""
|
| 76 |
+
echo "β
Deploy complete!"
|
| 77 |
+
echo " Frontend: https://${PROJECT_ID}.web.app"
|
| 78 |
+
echo " API: https://${PROJECT_ID}.web.app/api/health"
|
evidence/domain_credibility.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhilVerify β Domain Credibility Module (Phase 5)
|
| 3 |
+
Wraps domain_credibility.json to provide structured tier lookups
|
| 4 |
+
for evidence source URLs and news article domains.
|
| 5 |
+
|
| 6 |
+
Tiers:
|
| 7 |
+
Tier 1 (CREDIBLE) β Established PH news orgs (Rappler, Inquirer, GMA, etc.)
|
| 8 |
+
Tier 2 (SATIRE_OPINION) β Satire, opinion blogs, entertainment
|
| 9 |
+
Tier 3 (SUSPICIOUS) β Unknown / newly registered / low authority
|
| 10 |
+
Tier 4 (KNOWN_FAKE) β Vera Files blacklisted fake news sites
|
| 11 |
+
"""
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from enum import IntEnum
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from urllib.parse import urlparse
|
| 19 |
+
import functools
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
_DB_PATH = Path(__file__).parent.parent / "domain_credibility.json"
|
| 24 |
+
|
| 25 |
+
# Score adjustments per tier (applied in scoring engine)
|
| 26 |
+
TIER_SCORE_ADJUSTMENT: dict[int, float] = {
|
| 27 |
+
1: +20.0, # Established PH news β credibility boost
|
| 28 |
+
2: -5.0, # Satire/opinion β mild penalty
|
| 29 |
+
3: -10.0, # Unknown β moderate penalty
|
| 30 |
+
4: -35.0, # Known fake β heavy penalty
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
TIER_LABELS: dict[int, str] = {
|
| 34 |
+
1: "Credible",
|
| 35 |
+
2: "Satire/Opinion",
|
| 36 |
+
3: "Suspicious",
|
| 37 |
+
4: "Known Fake",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class DomainTier(IntEnum):
|
| 42 |
+
CREDIBLE = 1
|
| 43 |
+
SATIRE_OPINION = 2
|
| 44 |
+
SUSPICIOUS = 3
|
| 45 |
+
KNOWN_FAKE = 4
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class DomainResult:
|
| 50 |
+
domain: str
|
| 51 |
+
tier: DomainTier
|
| 52 |
+
tier_label: str
|
| 53 |
+
score_adjustment: float
|
| 54 |
+
matched_entry: str | None = None # Which entry in the JSON matched
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@functools.lru_cache(maxsize=1)
|
| 58 |
+
def _load_db() -> dict:
|
| 59 |
+
"""Load and cache the domain_credibility.json file."""
|
| 60 |
+
try:
|
| 61 |
+
data = json.loads(_DB_PATH.read_text())
|
| 62 |
+
total = sum(len(v.get("domains", [])) for v in data.values())
|
| 63 |
+
logger.info("domain_credibility.json loaded β %d domains across %d tiers", total, len(data))
|
| 64 |
+
return data
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error("Failed to load domain_credibility.json: %s", e)
|
| 67 |
+
return {}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def extract_domain(url_or_domain: str) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Normalize a URL or raw domain string to a bare hostname.
|
| 73 |
+
|
| 74 |
+
Examples:
|
| 75 |
+
"https://www.rappler.com/news/..." β "rappler.com"
|
| 76 |
+
"www.gmanetwork.com" β "gmanetwork.com"
|
| 77 |
+
"inquirer.net" β "inquirer.net"
|
| 78 |
+
"""
|
| 79 |
+
if not url_or_domain:
|
| 80 |
+
return ""
|
| 81 |
+
raw = url_or_domain.strip().lower()
|
| 82 |
+
# Add scheme if missing so urlparse works correctly
|
| 83 |
+
if not raw.startswith(("http://", "https://")):
|
| 84 |
+
raw = "https://" + raw
|
| 85 |
+
try:
|
| 86 |
+
hostname = urlparse(raw).hostname or ""
|
| 87 |
+
# Strip leading www.
|
| 88 |
+
hostname = re.sub(r"^www\.", "", hostname)
|
| 89 |
+
return hostname
|
| 90 |
+
except Exception:
|
| 91 |
+
# Last resort β strip www. manually
|
| 92 |
+
return re.sub(r"^www\.", "", raw.split("/")[0])
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def lookup_domain(url_or_domain: str) -> DomainResult:
|
| 96 |
+
"""
|
| 97 |
+
Classify a domain/URL against the credibility tier database.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
url_or_domain: Full URL or bare domain name.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
DomainResult β Tier 3 (Suspicious) by default for unknown domains.
|
| 104 |
+
"""
|
| 105 |
+
domain = extract_domain(url_or_domain)
|
| 106 |
+
if not domain:
|
| 107 |
+
return _make_result("", DomainTier.SUSPICIOUS, None)
|
| 108 |
+
|
| 109 |
+
db = _load_db()
|
| 110 |
+
|
| 111 |
+
for tier_key, tier_data in db.items():
|
| 112 |
+
tier_num = int(tier_key[-1]) # "tier1" β 1
|
| 113 |
+
for entry in tier_data.get("domains", []):
|
| 114 |
+
# Match exact domain or subdomain of listed domain
|
| 115 |
+
if domain == entry or domain.endswith(f".{entry}"):
|
| 116 |
+
return _make_result(domain, DomainTier(tier_num), entry)
|
| 117 |
+
|
| 118 |
+
# Not found β Tier 3 (Suspicious/Unknown)
|
| 119 |
+
logger.debug("Domain '%s' not in credibility DB β defaulting to Tier 3 (Suspicious)", domain)
|
| 120 |
+
return _make_result(domain, DomainTier.SUSPICIOUS, None)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _make_result(domain: str, tier: DomainTier, matched_entry: str | None) -> DomainResult:
|
| 124 |
+
return DomainResult(
|
| 125 |
+
domain=domain,
|
| 126 |
+
tier=tier,
|
| 127 |
+
tier_label=TIER_LABELS[tier.value],
|
| 128 |
+
score_adjustment=TIER_SCORE_ADJUSTMENT[tier.value],
|
| 129 |
+
matched_entry=matched_entry,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def get_tier_score(url_or_domain: str) -> float:
|
| 134 |
+
"""
|
| 135 |
+
Convenience: return just the score adjustment for a domain.
|
| 136 |
+
Positive = credibility boost, negative = penalty.
|
| 137 |
+
"""
|
| 138 |
+
return lookup_domain(url_or_domain).score_adjustment
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def is_blacklisted(url_or_domain: str) -> bool:
|
| 142 |
+
"""Return True if the domain is a known fake news / blacklisted site."""
|
| 143 |
+
return lookup_domain(url_or_domain).tier == DomainTier.KNOWN_FAKE
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def describe_tier(tier: DomainTier) -> str:
|
| 147 |
+
"""Human-readable tier description for API responses."""
|
| 148 |
+
db = _load_db()
|
| 149 |
+
key = f"tier{tier.value}"
|
| 150 |
+
return db.get(key, {}).get("description", TIER_LABELS[tier.value])
|
evidence/news_fetcher.py
CHANGED
|
@@ -1,20 +1,46 @@
|
|
| 1 |
"""
|
| 2 |
PhilVerify β Evidence Retrieval Module
|
| 3 |
-
Fetches related articles from
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
|
|
|
| 6 |
import logging
|
| 7 |
import hashlib
|
|
|
|
|
|
|
| 8 |
from dataclasses import dataclass, field
|
| 9 |
from pathlib import Path
|
| 10 |
import json
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
-
#
|
|
|
|
|
|
|
| 15 |
_CACHE_DIR = Path(__file__).parent.parent / ".cache" / "newsapi"
|
| 16 |
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
@dataclass
|
| 20 |
class ArticleResult:
|
|
@@ -36,8 +62,8 @@ class EvidenceResult:
|
|
| 36 |
claim_used: str = ""
|
| 37 |
|
| 38 |
|
| 39 |
-
def _cache_key(claim: str) -> str:
|
| 40 |
-
return hashlib.md5(claim.lower().strip().encode()).hexdigest()
|
| 41 |
|
| 42 |
|
| 43 |
def _load_cache(key: str) -> list[dict] | None:
|
|
@@ -52,41 +78,230 @@ def _load_cache(key: str) -> list[dict] | None:
|
|
| 52 |
|
| 53 |
def _save_cache(key: str, data: list[dict]) -> None:
|
| 54 |
path = _CACHE_DIR / f"{key}.json"
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
logger.info("NewsAPI cache hit for claim hash %s", key[:8])
|
| 64 |
-
return cached
|
| 65 |
|
| 66 |
-
|
| 67 |
-
logger.warning("
|
| 68 |
return []
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
try:
|
| 71 |
from newsapi import NewsApiClient
|
| 72 |
client = NewsApiClient(api_key=api_key)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
resp = client.get_everything(
|
| 76 |
q=query,
|
|
|
|
| 77 |
language="en",
|
| 78 |
sort_by="relevancy",
|
| 79 |
page_size=max_results,
|
| 80 |
)
|
| 81 |
articles = resp.get("articles", [])
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
return articles
|
| 85 |
-
except Exception as
|
| 86 |
-
logger.warning("NewsAPI fetch error: %s",
|
| 87 |
return []
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
def compute_similarity(claim: str, article_text: str) -> float:
|
| 91 |
"""
|
| 92 |
Compute cosine similarity between claim and article using sentence-transformers.
|
|
|
|
| 1 |
"""
|
| 2 |
PhilVerify β Evidence Retrieval Module
|
| 3 |
+
Fetches related articles from two sources and merges the results:
|
| 4 |
+
1. Google News RSS (gl=PH) β free, no API key, PH-indexed, primary source
|
| 5 |
+
2. NewsAPI /everything β broader English coverage, requires API key
|
| 6 |
+
|
| 7 |
+
Google News RSS is always attempted first since it covers local PH outlets
|
| 8 |
+
(GMA, Inquirer, Rappler, CNN Philippines, PhilStar, etc.) far better than
|
| 9 |
+
NewsAPI's free tier index.
|
| 10 |
"""
|
| 11 |
+
import asyncio
|
| 12 |
import logging
|
| 13 |
import hashlib
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
import urllib.parse
|
| 16 |
from dataclasses import dataclass, field
|
| 17 |
from pathlib import Path
|
| 18 |
import json
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
+
# ββ Cache βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
# Shared cache for both sources. NewsAPI free tier = 100 req/day.
|
| 24 |
+
# Google News RSS has no hard limit but we cache anyway to stay polite.
|
| 25 |
_CACHE_DIR = Path(__file__).parent.parent / ".cache" / "newsapi"
|
| 26 |
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 27 |
|
| 28 |
+
# ββ Philippine news domains (used to boost Google News RSS results) βββββββββββ
|
| 29 |
+
_PH_DOMAINS = {
|
| 30 |
+
"rappler.com", "inquirer.net", "gmanetwork.com", "philstar.com",
|
| 31 |
+
"manilatimes.net", "mb.com.ph", "abs-cbn.com", "cnnphilippines.com",
|
| 32 |
+
"pna.gov.ph", "sunstar.com.ph", "businessmirror.com.ph",
|
| 33 |
+
"businessworld.com.ph", "malaya.com.ph", "marikina.gov.ph",
|
| 34 |
+
"verafiles.org", "pcij.org", "interaksyon.philstar.com",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# NewsAPI domains filter β restricts results to PH outlets when API key is set
|
| 38 |
+
_NEWSAPI_PH_DOMAINS = ",".join([
|
| 39 |
+
"rappler.com", "inquirer.net", "gmanetwork.com", "philstar.com",
|
| 40 |
+
"manilatimes.net", "mb.com.ph", "abs-cbn.com", "cnnphilippines.com",
|
| 41 |
+
"pna.gov.ph", "sunstar.com.ph", "businessmirror.com.ph",
|
| 42 |
+
])
|
| 43 |
+
|
| 44 |
|
| 45 |
@dataclass
|
| 46 |
class ArticleResult:
|
|
|
|
| 62 |
claim_used: str = ""
|
| 63 |
|
| 64 |
|
| 65 |
+
def _cache_key(prefix: str, claim: str) -> str:
|
| 66 |
+
return f"{prefix}_{hashlib.md5(claim.lower().strip().encode()).hexdigest()}"
|
| 67 |
|
| 68 |
|
| 69 |
def _load_cache(key: str) -> list[dict] | None:
|
|
|
|
| 78 |
|
| 79 |
def _save_cache(key: str, data: list[dict]) -> None:
|
| 80 |
path = _CACHE_DIR / f"{key}.json"
|
| 81 |
+
try:
|
| 82 |
+
path.write_text(json.dumps(data))
|
| 83 |
+
except Exception:
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _extract_domain(url: str) -> str:
|
| 88 |
+
"""Return bare domain from a URL string."""
|
| 89 |
+
try:
|
| 90 |
+
from urllib.parse import urlparse
|
| 91 |
+
host = urlparse(url).hostname or ""
|
| 92 |
+
return host.removeprefix("www.")
|
| 93 |
+
except Exception:
|
| 94 |
+
return ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _is_ph_article(article: dict) -> bool:
|
| 98 |
+
"""
|
| 99 |
+
Return True if the article appears to be from a Philippine outlet.
|
| 100 |
+
Checks the source name since Google News RSS links are redirect URLs.
|
| 101 |
+
"""
|
| 102 |
+
src = (article.get("source", {}) or {}).get("name", "").lower()
|
| 103 |
+
url = article.get("url", "").lower()
|
| 104 |
+
# Direct domain match on URL (works for NewsAPI results)
|
| 105 |
+
if _extract_domain(url) in _PH_DOMAINS:
|
| 106 |
+
return True
|
| 107 |
+
# Source-name match (works for Google News RSS redirect URLs)
|
| 108 |
+
_PH_SOURCE_KEYWORDS = {
|
| 109 |
+
"rappler", "inquirer", "gma", "abs-cbn", "cnn philippines",
|
| 110 |
+
"philstar", "manila times", "manila bulletin", "sunstar",
|
| 111 |
+
"businessworld", "business mirror", "malaya", "philippine news agency",
|
| 112 |
+
"pna", "vera files", "pcij", "interaksyon",
|
| 113 |
+
}
|
| 114 |
+
return any(kw in src for kw in _PH_SOURCE_KEYWORDS)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _build_query(claim: str, entities: list[str] | None) -> str:
|
| 118 |
+
"""Build a concise search query from entities or the first words of the claim."""
|
| 119 |
+
if entities:
|
| 120 |
+
return " ".join(entities[:3])
|
| 121 |
+
words = claim.split()
|
| 122 |
+
return " ".join(words[:6])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ββ Google News RSS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
|
| 127 |
+
def _fetch_gnews_rss(query: str, max_results: int = 5) -> list[dict]:
|
| 128 |
+
"""
|
| 129 |
+
Fetch articles from Google News RSS scoped to the Philippines.
|
| 130 |
+
Returns a list of dicts in the same shape as NewsAPI articles so the
|
| 131 |
+
rest of the pipeline can treat both sources uniformly.
|
| 132 |
+
No API key required.
|
| 133 |
+
"""
|
| 134 |
+
encoded = urllib.parse.quote(query)
|
| 135 |
+
url = (
|
| 136 |
+
f"https://news.google.com/rss/search"
|
| 137 |
+
f"?q={encoded}&gl=PH&hl=en-PH&ceid=PH:en"
|
| 138 |
+
)
|
| 139 |
+
try:
|
| 140 |
+
import requests as req_lib
|
| 141 |
+
resp = req_lib.get(url, headers={"User-Agent": "PhilVerify/1.0"}, timeout=10)
|
| 142 |
+
resp.raise_for_status()
|
| 143 |
+
raw = resp.content
|
| 144 |
+
root = ET.fromstring(raw)
|
| 145 |
+
channel = root.find("channel")
|
| 146 |
+
if channel is None:
|
| 147 |
+
return []
|
| 148 |
+
|
| 149 |
+
articles: list[dict] = []
|
| 150 |
+
for item in channel.findall("item")[:max_results]:
|
| 151 |
+
title_el = item.find("title")
|
| 152 |
+
link_el = item.find("link")
|
| 153 |
+
desc_el = item.find("description")
|
| 154 |
+
pub_el = item.find("pubDate")
|
| 155 |
+
src_el = item.find("source")
|
| 156 |
+
|
| 157 |
+
title = title_el.text if title_el is not None else ""
|
| 158 |
+
link = link_el.text if link_el is not None else ""
|
| 159 |
+
description = desc_el.text if desc_el is not None else ""
|
| 160 |
+
pub_date = pub_el.text if pub_el is not None else ""
|
| 161 |
+
src_name = src_el.text if src_el is not None else _extract_domain(link)
|
| 162 |
+
|
| 163 |
+
# Google News titles often include "- Source" suffix β strip it
|
| 164 |
+
if src_name and title.endswith(f" - {src_name}"):
|
| 165 |
+
title = title[: -(len(src_name) + 3)].strip()
|
| 166 |
|
| 167 |
+
articles.append({
|
| 168 |
+
"title": title,
|
| 169 |
+
"url": link,
|
| 170 |
+
"description": description or title,
|
| 171 |
+
"publishedAt": pub_date,
|
| 172 |
+
"source": {"name": src_name},
|
| 173 |
+
"_gnews": True, # Tag so we can log the origin
|
| 174 |
+
})
|
| 175 |
|
| 176 |
+
logger.info(
|
| 177 |
+
"Google News RSS (PH) returned %d articles for query '%s...'",
|
| 178 |
+
len(articles), query[:40],
|
| 179 |
+
)
|
| 180 |
+
return articles
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
except Exception as exc:
|
| 183 |
+
logger.warning("Google News RSS fetch failed: %s", exc)
|
| 184 |
return []
|
| 185 |
|
| 186 |
+
|
| 187 |
+
# ββ NewsAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 188 |
+
|
| 189 |
+
def _fetch_newsapi(query: str, api_key: str, max_results: int = 5) -> list[dict]:
|
| 190 |
+
"""
|
| 191 |
+
Fetch from NewsAPI /everything, restricted to PH domains.
|
| 192 |
+
Falls back to global search if the PH-domains query returns < 2 results.
|
| 193 |
+
"""
|
| 194 |
try:
|
| 195 |
from newsapi import NewsApiClient
|
| 196 |
client = NewsApiClient(api_key=api_key)
|
| 197 |
+
|
| 198 |
+
# Try Philippine outlets first
|
| 199 |
resp = client.get_everything(
|
| 200 |
q=query,
|
| 201 |
+
domains=_NEWSAPI_PH_DOMAINS,
|
| 202 |
language="en",
|
| 203 |
sort_by="relevancy",
|
| 204 |
page_size=max_results,
|
| 205 |
)
|
| 206 |
articles = resp.get("articles", [])
|
| 207 |
+
|
| 208 |
+
# If PH domains yield nothing useful, fall back to global
|
| 209 |
+
if len(articles) < 2:
|
| 210 |
+
logger.debug("NewsAPI PH-domains sparse (%d) β retrying global", len(articles))
|
| 211 |
+
resp = client.get_everything(
|
| 212 |
+
q=query,
|
| 213 |
+
language="en",
|
| 214 |
+
sort_by="relevancy",
|
| 215 |
+
page_size=max_results,
|
| 216 |
+
)
|
| 217 |
+
articles = resp.get("articles", [])
|
| 218 |
+
|
| 219 |
+
logger.info(
|
| 220 |
+
"NewsAPI returned %d articles for query '%s...'",
|
| 221 |
+
len(articles), query[:40],
|
| 222 |
+
)
|
| 223 |
return articles
|
| 224 |
+
except Exception as exc:
|
| 225 |
+
logger.warning("NewsAPI fetch error: %s", exc)
|
| 226 |
return []
|
| 227 |
|
| 228 |
|
| 229 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
+
|
| 231 |
+
async def fetch_evidence(
|
| 232 |
+
claim: str,
|
| 233 |
+
api_key: str,
|
| 234 |
+
entities: list[str] = None,
|
| 235 |
+
max_results: int = 5,
|
| 236 |
+
) -> list[dict]:
|
| 237 |
+
"""
|
| 238 |
+
Fetch the most relevant articles for a claim by merging:
|
| 239 |
+
1. Google News RSS (PH-scoped) β always attempted, no key needed
|
| 240 |
+
2. NewsAPI β only when NEWS_API_KEY is configured
|
| 241 |
+
|
| 242 |
+
Results are deduplicated by domain and capped at max_results.
|
| 243 |
+
PH-domain articles are surfaced first so scoring reflects local coverage.
|
| 244 |
+
"""
|
| 245 |
+
query = _build_query(claim, entities)
|
| 246 |
+
|
| 247 |
+
# ββ Google News RSS (check cache) βββββββββββββββββββββββββββββββββββββββββ
|
| 248 |
+
gnews_key = _cache_key("gnews", query)
|
| 249 |
+
gnews_articles = _load_cache(gnews_key)
|
| 250 |
+
if gnews_articles is None:
|
| 251 |
+
# Run blocking RSS fetch in a thread so we don't block the event loop
|
| 252 |
+
gnews_articles = await asyncio.get_event_loop().run_in_executor(
|
| 253 |
+
None, _fetch_gnews_rss, query, max_results
|
| 254 |
+
)
|
| 255 |
+
_save_cache(gnews_key, gnews_articles)
|
| 256 |
+
else:
|
| 257 |
+
logger.info("Google News RSS cache hit for query hash %s", gnews_key[-8:])
|
| 258 |
+
|
| 259 |
+
# ββ NewsAPI (check cache) βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
+
newsapi_articles: list[dict] = []
|
| 261 |
+
if api_key:
|
| 262 |
+
newsapi_key = _cache_key("newsapi", query)
|
| 263 |
+
newsapi_articles = _load_cache(newsapi_key)
|
| 264 |
+
if newsapi_articles is None:
|
| 265 |
+
newsapi_articles = await asyncio.get_event_loop().run_in_executor(
|
| 266 |
+
None, _fetch_newsapi, query, api_key, max_results
|
| 267 |
+
)
|
| 268 |
+
_save_cache(newsapi_key, newsapi_articles)
|
| 269 |
+
else:
|
| 270 |
+
logger.info("NewsAPI cache hit for query hash %s", newsapi_key[-8:])
|
| 271 |
+
|
| 272 |
+
# ββ Merge: PH articles first, then global, deduplicated by domain βββββββββ
|
| 273 |
+
seen_domains: set[str] = set()
|
| 274 |
+
merged: list[dict] = []
|
| 275 |
+
|
| 276 |
+
def _add(articles: list[dict]) -> None:
|
| 277 |
+
for art in articles:
|
| 278 |
+
url = art.get("url", "")
|
| 279 |
+
domain = _extract_domain(url)
|
| 280 |
+
# For Google News redirect URLs, deduplicate by source name instead
|
| 281 |
+
dedup_key = domain if domain and "google.com" not in domain \
|
| 282 |
+
else (art.get("source", {}) or {}).get("name", url)
|
| 283 |
+
if dedup_key and dedup_key in seen_domains:
|
| 284 |
+
continue
|
| 285 |
+
if dedup_key:
|
| 286 |
+
seen_domains.add(dedup_key)
|
| 287 |
+
merged.append(art)
|
| 288 |
+
|
| 289 |
+
# PH-source Google News articles go first
|
| 290 |
+
ph_gnews = [a for a in gnews_articles if _is_ph_article(a)]
|
| 291 |
+
other_gnews = [a for a in gnews_articles if not _is_ph_article(a)]
|
| 292 |
+
|
| 293 |
+
_add(ph_gnews)
|
| 294 |
+
_add(newsapi_articles)
|
| 295 |
+
_add(other_gnews) # non-PH Google News last
|
| 296 |
+
|
| 297 |
+
result = merged[:max_results]
|
| 298 |
+
logger.info(
|
| 299 |
+
"Evidence merged: %d PH-gnews + %d newsapi + %d other β %d final",
|
| 300 |
+
len(ph_gnews), len(newsapi_articles), len(other_gnews), len(result),
|
| 301 |
+
)
|
| 302 |
+
return result
|
| 303 |
+
|
| 304 |
+
|
| 305 |
def compute_similarity(claim: str, article_text: str) -> float:
|
| 306 |
"""
|
| 307 |
Compute cosine similarity between claim and article using sentence-transformers.
|
evidence/similarity.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhilVerify β Similarity Module (Phase 5)
|
| 3 |
+
Computes semantic similarity between a claim and evidence article text.
|
| 4 |
+
Primary: sentence-transformers/all-MiniLM-L6-v2 (cosine similarity)
|
| 5 |
+
Fallback: Jaccard word-overlap similarity
|
| 6 |
+
"""
|
| 7 |
+
import logging
|
| 8 |
+
import functools
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Lazy-load the model at first use β avoids blocking app startup
|
| 13 |
+
@functools.lru_cache(maxsize=1)
|
| 14 |
+
def _get_model():
|
| 15 |
+
"""Load sentence-transformer model once and cache it."""
|
| 16 |
+
try:
|
| 17 |
+
from sentence_transformers import SentenceTransformer
|
| 18 |
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 19 |
+
logger.info("sentence-transformers model loaded: all-MiniLM-L6-v2")
|
| 20 |
+
return model
|
| 21 |
+
except Exception as e:
|
| 22 |
+
logger.warning("sentence-transformers unavailable (%s) β Jaccard fallback active", e)
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def compute_similarity(claim: str, article_text: str) -> float:
|
| 27 |
+
"""
|
| 28 |
+
Compute semantic similarity between a fact-check claim and an article.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
claim: The extracted falsifiable claim sentence.
|
| 32 |
+
article_text: Title + description of a retrieved news article.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Float in [0.0, 1.0] β higher means more semantically related.
|
| 36 |
+
"""
|
| 37 |
+
if not claim or not article_text:
|
| 38 |
+
return 0.0
|
| 39 |
+
|
| 40 |
+
model = _get_model()
|
| 41 |
+
if model is not None:
|
| 42 |
+
try:
|
| 43 |
+
from sentence_transformers import util
|
| 44 |
+
emb_claim = model.encode(claim, convert_to_tensor=True)
|
| 45 |
+
emb_article = model.encode(article_text[:512], convert_to_tensor=True)
|
| 46 |
+
score = float(util.cos_sim(emb_claim, emb_article)[0][0])
|
| 47 |
+
return round(max(0.0, min(1.0, score)), 4)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.warning("Embedding similarity failed (%s) β falling back to Jaccard", e)
|
| 50 |
+
|
| 51 |
+
# Jaccard token-overlap fallback
|
| 52 |
+
return _jaccard_similarity(claim, article_text)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _jaccard_similarity(a: str, b: str) -> float:
|
| 56 |
+
"""Simple set-based Jaccard similarity on word tokens."""
|
| 57 |
+
tokens_a = set(a.lower().split())
|
| 58 |
+
tokens_b = set(b.lower().split())
|
| 59 |
+
if not tokens_a or not tokens_b:
|
| 60 |
+
return 0.0
|
| 61 |
+
intersection = tokens_a & tokens_b
|
| 62 |
+
union = tokens_a | tokens_b
|
| 63 |
+
return round(len(intersection) / len(union), 4)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def rank_articles_by_similarity(claim: str, articles: list[dict]) -> list[dict]:
|
| 67 |
+
"""
|
| 68 |
+
Annotate and sort a list of NewsAPI article dicts by similarity to the claim.
|
| 69 |
+
|
| 70 |
+
Each article dict gets a `similarity` key added.
|
| 71 |
+
Returns articles sorted descending by similarity.
|
| 72 |
+
"""
|
| 73 |
+
scored = []
|
| 74 |
+
for article in articles:
|
| 75 |
+
article_text = f"{article.get('title', '')} {article.get('description', '')}"
|
| 76 |
+
sim = compute_similarity(claim, article_text)
|
| 77 |
+
scored.append({**article, "similarity": sim})
|
| 78 |
+
|
| 79 |
+
scored.sort(key=lambda x: x["similarity"], reverse=True)
|
| 80 |
+
return scored
|
evidence/stance_detector.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhilVerify β Stance Detection Module (Phase 5)
|
| 3 |
+
Classifies the relationship between a claim and a retrieved evidence article.
|
| 4 |
+
|
| 5 |
+
Stance labels:
|
| 6 |
+
Supports β article content supports the claim
|
| 7 |
+
Refutes β article content contradicts / debunks the claim
|
| 8 |
+
Not Enough Info β article is related but not conclusive either way
|
| 9 |
+
|
| 10 |
+
Strategy (rule-based hybrid β no heavy model dependency):
|
| 11 |
+
1. Keyword scan of title + description for refutation/support signals
|
| 12 |
+
2. Similarity threshold guard β low similarity β NEI
|
| 13 |
+
3. Factuality keywords override similarity-based detection
|
| 14 |
+
"""
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from enum import Enum
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Stance(str, Enum):
|
| 24 |
+
SUPPORTS = "Supports"
|
| 25 |
+
REFUTES = "Refutes"
|
| 26 |
+
NOT_ENOUGH_INFO = "Not Enough Info"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ββ Keyword Lists βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
# Ordered: check REFUTATION first (stronger signal), then SUPPORT
|
| 31 |
+
_REFUTATION_KEYWORDS = [
|
| 32 |
+
# Fact-check verdicts
|
| 33 |
+
r"\bfact.?check\b", r"\bfalse\b", r"\bfake\b", r"\bhoax\b",
|
| 34 |
+
r"\bdebunked\b", r"\bmisinformation\b", r"\bdisinformation\b",
|
| 35 |
+
r"\bnot true\b", r"\bno evidence\b", r"\bunverified\b",
|
| 36 |
+
r"\bcorrection\b", r"\bretract\b", r"\bwrong\b", r"\bdenied\b",
|
| 37 |
+
r"\bscam\b", r"\bsatire\b",
|
| 38 |
+
# Filipino equivalents
|
| 39 |
+
r"\bkasinungalingan\b", r"\bhindi totoo\b", r"\bpeke\b",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
_SUPPORT_KEYWORDS = [
|
| 43 |
+
r"\bconfirmed\b", r"\bverified\b", r"\bofficial\b", r"\bproven\b",
|
| 44 |
+
r"\btrue\b", r"\blegitimate\b", r"\baccurate\b", r"\bauthorized\b",
|
| 45 |
+
r"\breal\b", r"\bgenuine\b",
|
| 46 |
+
# Filipino equivalents
|
| 47 |
+
r"\btotoo\b", r"\bkumpirmado\b", r"\bopisyal\b",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
# Articles from these PH fact-check domains always β Refutes regardless of content
|
| 51 |
+
_FACTCHECK_DOMAINS = {
|
| 52 |
+
"vera-files.org", "verafiles.org", "factcheck.afp.com",
|
| 53 |
+
"rappler.com/newsbreak/fact-check", "cnn.ph/fact-check",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Similarity threshold: below this β NEI even with support keywords
|
| 57 |
+
_SIMILARITY_NEI_THRESHOLD = 0.15
|
| 58 |
+
# Similarity above this + support keywords β Supports
|
| 59 |
+
_SIMILARITY_SUPPORT_THRESHOLD = 0.35
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class StanceResult:
|
| 64 |
+
stance: Stance
|
| 65 |
+
confidence: float # 0.0β1.0 β how confident we are in this label
|
| 66 |
+
matched_keywords: list[str]
|
| 67 |
+
reason: str
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def detect_stance(
|
| 71 |
+
claim: str,
|
| 72 |
+
article_title: str,
|
| 73 |
+
article_description: str,
|
| 74 |
+
article_url: str = "",
|
| 75 |
+
similarity: float = 0.0,
|
| 76 |
+
) -> StanceResult:
|
| 77 |
+
"""
|
| 78 |
+
Detect the stance of an article relative to the claim.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
claim: The extracted falsifiable claim.
|
| 82 |
+
article_title: NewsAPI article title.
|
| 83 |
+
article_description: NewsAPI article description.
|
| 84 |
+
article_url: Article URL (used for fact-check domain detection).
|
| 85 |
+
similarity: Pre-computed cosine similarity score (0β1).
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
StanceResult with stance label, confidence, and reason.
|
| 89 |
+
"""
|
| 90 |
+
# Combine article text for keyword search
|
| 91 |
+
article_text = f"{article_title} {article_description}".lower()
|
| 92 |
+
|
| 93 |
+
# ββ Rule 0: Known fact-check domain β always Refutes ββββββββββββββββββββββ
|
| 94 |
+
if article_url:
|
| 95 |
+
for fc_domain in _FACTCHECK_DOMAINS:
|
| 96 |
+
if fc_domain in article_url.lower():
|
| 97 |
+
return StanceResult(
|
| 98 |
+
stance=Stance.REFUTES,
|
| 99 |
+
confidence=0.90,
|
| 100 |
+
matched_keywords=[fc_domain],
|
| 101 |
+
reason="Known Philippine fact-check domain",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# ββ Rule 1: Similarity floor β too low to make any claim ββββββββββββββββββ
|
| 105 |
+
if similarity < _SIMILARITY_NEI_THRESHOLD:
|
| 106 |
+
return StanceResult(
|
| 107 |
+
stance=Stance.NOT_ENOUGH_INFO,
|
| 108 |
+
confidence=0.80,
|
| 109 |
+
matched_keywords=[],
|
| 110 |
+
reason=f"Low similarity ({similarity:.2f}) β article not related to claim",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# ββ Rule 2: Scan for refutation keywords ββββββββββββββββββββββββββββββββββ
|
| 114 |
+
refutation_hits = _scan_keywords(article_text, _REFUTATION_KEYWORDS)
|
| 115 |
+
if refutation_hits:
|
| 116 |
+
confidence = min(0.95, 0.65 + len(refutation_hits) * 0.10)
|
| 117 |
+
return StanceResult(
|
| 118 |
+
stance=Stance.REFUTES,
|
| 119 |
+
confidence=round(confidence, 2),
|
| 120 |
+
matched_keywords=refutation_hits,
|
| 121 |
+
reason=f"Refutation signal detected: {', '.join(refutation_hits[:3])}",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# ββ Rule 3: Scan for support keywords + similarity threshold ββββββββββββββ
|
| 125 |
+
support_hits = _scan_keywords(article_text, _SUPPORT_KEYWORDS)
|
| 126 |
+
if support_hits and similarity >= _SIMILARITY_SUPPORT_THRESHOLD:
|
| 127 |
+
confidence = min(0.90, 0.50 + len(support_hits) * 0.10 + similarity * 0.20)
|
| 128 |
+
return StanceResult(
|
| 129 |
+
stance=Stance.SUPPORTS,
|
| 130 |
+
confidence=round(confidence, 2),
|
| 131 |
+
matched_keywords=support_hits,
|
| 132 |
+
reason=f"Support signal + similarity {similarity:.2f}: {', '.join(support_hits[:3])}",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# ββ Default: Not Enough Info βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
return StanceResult(
|
| 137 |
+
stance=Stance.NOT_ENOUGH_INFO,
|
| 138 |
+
confidence=0.70,
|
| 139 |
+
matched_keywords=[],
|
| 140 |
+
reason="No conclusive support or refutation signals found",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _scan_keywords(text: str, patterns: list[str]) -> list[str]:
|
| 145 |
+
"""Return list of matched keyword patterns found in text."""
|
| 146 |
+
hits = []
|
| 147 |
+
for pattern in patterns:
|
| 148 |
+
match = re.search(pattern, text, re.IGNORECASE)
|
| 149 |
+
if match:
|
| 150 |
+
hits.append(match.group(0))
|
| 151 |
+
return hits
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def compute_evidence_score(
|
| 155 |
+
stances: list[StanceResult],
|
| 156 |
+
similarities: list[float],
|
| 157 |
+
) -> tuple[float, str]:
|
| 158 |
+
"""
|
| 159 |
+
Aggregate multiple article stances into a single evidence score (0β100)
|
| 160 |
+
and an overall Layer 2 verdict.
|
| 161 |
+
|
| 162 |
+
Scoring:
|
| 163 |
+
- Start at neutral 50
|
| 164 |
+
- Each Supports article: +10 Γ similarity bonus
|
| 165 |
+
- Each Refutes article: -15 penalty (stronger signal)
|
| 166 |
+
- NEI articles: no effect
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
(evidence_score, verdict_label)
|
| 170 |
+
"""
|
| 171 |
+
if not stances:
|
| 172 |
+
return 50.0, "Unverified"
|
| 173 |
+
|
| 174 |
+
score = 50.0
|
| 175 |
+
supporting = [s for s in stances if s.stance == Stance.SUPPORTS]
|
| 176 |
+
refuting = [s for s in stances if s.stance == Stance.REFUTES]
|
| 177 |
+
|
| 178 |
+
for i, stance in enumerate(stances):
|
| 179 |
+
sim = similarities[i] if i < len(similarities) else 0.5
|
| 180 |
+
if stance.stance == Stance.SUPPORTS:
|
| 181 |
+
score += 10.0 * (0.5 + sim)
|
| 182 |
+
elif stance.stance == Stance.REFUTES:
|
| 183 |
+
score -= 15.0 * stance.confidence
|
| 184 |
+
|
| 185 |
+
score = round(max(0.0, min(100.0, score)), 1)
|
| 186 |
+
|
| 187 |
+
if len(refuting) > len(supporting):
|
| 188 |
+
verdict = "Likely Fake"
|
| 189 |
+
elif len(supporting) >= 2 and score >= 60:
|
| 190 |
+
verdict = "Credible"
|
| 191 |
+
else:
|
| 192 |
+
verdict = "Unverified"
|
| 193 |
+
|
| 194 |
+
return score, verdict
|
extension/background.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PhilVerify β Background Service Worker (Manifest V3)
|
| 3 |
+
*
|
| 4 |
+
* Responsibilities:
|
| 5 |
+
* - Proxy API calls to the PhilVerify FastAPI backend
|
| 6 |
+
* - File-based cache via chrome.storage.local (24-hour TTL, max 50 entries)
|
| 7 |
+
* - Maintain personal verification history
|
| 8 |
+
* - Respond to messages from content.js and popup.js
|
| 9 |
+
*
|
| 10 |
+
* Message types handled:
|
| 11 |
+
* VERIFY_TEXT { text } β VerificationResponse
|
| 12 |
+
* VERIFY_URL { url } β VerificationResponse
|
| 13 |
+
* GET_HISTORY {} β { history: HistoryEntry[] }
|
| 14 |
+
* GET_SETTINGS {} β { apiBase, autoScan }
|
| 15 |
+
* SAVE_SETTINGS { apiBase, autoScan } β {}
|
| 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: 'http://localhost:8000',
|
| 24 |
+
autoScan: true, // Automatically scan Facebook feed posts
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
/** Validate that a string is a safe http/https URL */
|
| 29 |
+
function isHttpUrl(str) {
|
| 30 |
+
if (!str || typeof str !== 'string') return false
|
| 31 |
+
try {
|
| 32 |
+
const u = new URL(str)
|
| 33 |
+
return u.protocol === 'http:' || u.protocol === 'https:'
|
| 34 |
+
} catch { return false }
|
| 35 |
+
}
|
| 36 |
+
async function sha256prefix(text, len = 16) {
|
| 37 |
+
const buf = await crypto.subtle.digest(
|
| 38 |
+
'SHA-256',
|
| 39 |
+
new TextEncoder().encode(text.trim().toLowerCase()),
|
| 40 |
+
)
|
| 41 |
+
return Array.from(new Uint8Array(buf))
|
| 42 |
+
.map(b => b.toString(16).padStart(2, '0'))
|
| 43 |
+
.join('')
|
| 44 |
+
.slice(0, len)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function getSettings() {
|
| 48 |
+
const stored = await chrome.storage.local.get('settings')
|
| 49 |
+
return { ...DEFAULT_SETTINGS, ...(stored.settings ?? {}) }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ββ Cache helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
async function getCached(key) {
|
| 55 |
+
const stored = await chrome.storage.local.get(key)
|
| 56 |
+
const entry = stored[key]
|
| 57 |
+
if (!entry) return null
|
| 58 |
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
| 59 |
+
await chrome.storage.local.remove(key)
|
| 60 |
+
return null
|
| 61 |
+
}
|
| 62 |
+
return entry.result
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function setCached(key, result, preview) {
|
| 66 |
+
await chrome.storage.local.set({
|
| 67 |
+
[key]: { result, timestamp: Date.now() },
|
| 68 |
+
})
|
| 69 |
+
|
| 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 })
|
| 81 |
+
}
|
| 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(() => ({}))
|
| 98 |
+
throw new Error(body.detail ?? `API error ${res.status}`)
|
| 99 |
+
}
|
| 100 |
+
const result = await res.json()
|
| 101 |
+
await setCached(key, result, text)
|
| 102 |
+
return result
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
async function verifyUrl(url) {
|
| 106 |
+
const key = 'url_' + await sha256prefix(url)
|
| 107 |
+
const hit = await getCached(key)
|
| 108 |
+
if (hit) return { ...hit, _fromCache: true }
|
| 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(() => ({}))
|
| 118 |
+
throw new Error(body.detail ?? `API error ${res.status}`)
|
| 119 |
+
}
|
| 120 |
+
const result = await res.json()
|
| 121 |
+
await setCached(key, result, url)
|
| 122 |
+
return result
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// ββ Message handler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
|
| 127 |
+
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
| 128 |
+
switch (msg.type) {
|
| 129 |
+
|
| 130 |
+
case 'VERIFY_TEXT':
|
| 131 |
+
verifyText(msg.text)
|
| 132 |
+
.then(r => sendResponse({ ok: true, result: r }))
|
| 133 |
+
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 134 |
+
return true // keep message channel open for async response
|
| 135 |
+
|
| 136 |
+
case 'VERIFY_URL':
|
| 137 |
+
if (!isHttpUrl(msg.url)) {
|
| 138 |
+
sendResponse({ ok: false, error: 'Invalid URL: only http/https allowed' })
|
| 139 |
+
return false
|
| 140 |
+
}
|
| 141 |
+
verifyUrl(msg.url)
|
| 142 |
+
.then(r => sendResponse({ ok: true, result: r }))
|
| 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 }))
|
| 149 |
+
return true
|
| 150 |
+
|
| 151 |
+
case 'GET_SETTINGS':
|
| 152 |
+
getSettings().then(s => sendResponse(s))
|
| 153 |
+
return true
|
| 154 |
+
|
| 155 |
+
case 'SAVE_SETTINGS': {
|
| 156 |
+
const incoming = msg.settings ?? {}
|
| 157 |
+
// Validate apiBase is a safe URL before persisting
|
| 158 |
+
if (incoming.apiBase && !isHttpUrl(incoming.apiBase)) {
|
| 159 |
+
sendResponse({ ok: false, error: 'Invalid API URL: only http/https allowed' })
|
| 160 |
+
return false
|
| 161 |
+
}
|
| 162 |
+
chrome.storage.local
|
| 163 |
+
.set({ settings: incoming })
|
| 164 |
+
.then(() => sendResponse({ ok: true }))
|
| 165 |
+
return true
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
default:
|
| 169 |
+
break
|
| 170 |
+
}
|
| 171 |
+
})
|
extension/content.css
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 180 |
+
font-weight: 700;
|
| 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 |
+
}
|
extension/content.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
/** Minimum text length to send for verification (avoids verifying 1-word posts) */
|
| 21 |
+
const MIN_TEXT_LENGTH = 40
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Facebook feed post selectors β ordered by reliability.
|
| 25 |
+
* Facebook's class names are obfuscated; structural role/data attributes are
|
| 26 |
+
* more stable across renames.
|
| 27 |
+
*/
|
| 28 |
+
const POST_SELECTORS = [
|
| 29 |
+
'[data-pagelet^="FeedUnit"]',
|
| 30 |
+
'[data-pagelet^="GroupsFeedUnit"]',
|
| 31 |
+
'[role="article"]',
|
| 32 |
+
'[data-testid="post_message"]',
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
const VERDICT_COLORS = {
|
| 36 |
+
'Credible': '#16a34a',
|
| 37 |
+
'Unverified': '#d97706',
|
| 38 |
+
'Likely Fake': '#dc2626',
|
| 39 |
+
}
|
| 40 |
+
const VERDICT_LABELS = {
|
| 41 |
+
'Credible': 'β Credible',
|
| 42 |
+
'Unverified': '? Unverified',
|
| 43 |
+
'Likely Fake': 'β Likely Fake',
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
|
| 48 |
+
/** Escape HTML special chars to prevent XSS in innerHTML templates */
|
| 49 |
+
function safeText(str) {
|
| 50 |
+
if (str == null) return ''
|
| 51 |
+
return String(str)
|
| 52 |
+
.replace(/&/g, '&')
|
| 53 |
+
.replace(/</g, '<')
|
| 54 |
+
.replace(/>/g, '>')
|
| 55 |
+
.replace(/"/g, '"')
|
| 56 |
+
.replace(/'/g, ''')
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Allow only http/https URLs; return '#' for anything else */
|
| 60 |
+
function safeUrl(url) {
|
| 61 |
+
if (!url) return '#'
|
| 62 |
+
try {
|
| 63 |
+
const u = new URL(url)
|
| 64 |
+
return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '#'
|
| 65 |
+
} catch { return '#' }
|
| 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 |
+
}
|
| 88 |
+
return null
|
| 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
|
| 116 |
+
const label = (post.getAttribute('aria-label') ?? '').replace(/\W/g, '').slice(0, 20)
|
| 117 |
+
return 'pv_' + label + crypto.randomUUID().replace(/-/g, '').slice(0, 12)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// ββ Badge rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
|
| 122 |
+
function createBadge(verdict, score, result) {
|
| 123 |
+
const color = VERDICT_COLORS[verdict] ?? '#5c554e'
|
| 124 |
+
const label = VERDICT_LABELS[verdict] ?? verdict
|
| 125 |
+
|
| 126 |
+
const wrap = document.createElement('div')
|
| 127 |
+
wrap.className = 'pv-badge'
|
| 128 |
+
wrap.setAttribute('role', 'status')
|
| 129 |
+
wrap.setAttribute('aria-label', `PhilVerify: ${label} β ${Math.round(score)}% credibility score`)
|
| 130 |
+
wrap.style.cssText = `
|
| 131 |
+
display: inline-flex;
|
| 132 |
+
align-items: center;
|
| 133 |
+
gap: 6px;
|
| 134 |
+
padding: 4px 10px;
|
| 135 |
+
border-radius: 3px;
|
| 136 |
+
border: 1px solid ${color}4d;
|
| 137 |
+
background: ${color}14;
|
| 138 |
+
cursor: pointer;
|
| 139 |
+
font-family: system-ui, sans-serif;
|
| 140 |
+
font-size: 11px;
|
| 141 |
+
font-weight: 600;
|
| 142 |
+
letter-spacing: 0.04em;
|
| 143 |
+
color: ${color};
|
| 144 |
+
touch-action: manipulation;
|
| 145 |
+
-webkit-tap-highlight-color: transparent;
|
| 146 |
+
position: relative;
|
| 147 |
+
z-index: 10;
|
| 148 |
+
`
|
| 149 |
+
|
| 150 |
+
const dot = document.createElement('span')
|
| 151 |
+
dot.style.cssText = `
|
| 152 |
+
width: 7px; height: 7px;
|
| 153 |
+
border-radius: 50%;
|
| 154 |
+
background: ${color};
|
| 155 |
+
flex-shrink: 0;
|
| 156 |
+
`
|
| 157 |
+
|
| 158 |
+
const text = document.createElement('span')
|
| 159 |
+
text.textContent = `${label} ${Math.round(score)}%`
|
| 160 |
+
|
| 161 |
+
const cacheTag = result._fromCache
|
| 162 |
+
? (() => { const t = document.createElement('span'); t.textContent = 'Β·cached'; t.style.cssText = `opacity:0.5;font-size:9px;`; return t })()
|
| 163 |
+
: null
|
| 164 |
+
|
| 165 |
+
wrap.appendChild(dot)
|
| 166 |
+
wrap.appendChild(text)
|
| 167 |
+
if (cacheTag) wrap.appendChild(cacheTag)
|
| 168 |
+
|
| 169 |
+
// Click β toggle detail panel
|
| 170 |
+
wrap.addEventListener('click', (e) => {
|
| 171 |
+
e.stopPropagation()
|
| 172 |
+
toggleDetailPanel(wrap, result)
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
return wrap
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function toggleDetailPanel(badge, result) {
|
| 179 |
+
const existing = badge.parentElement?.querySelector('.pv-detail')
|
| 180 |
+
if (existing) { existing.remove(); return }
|
| 181 |
+
|
| 182 |
+
const panel = document.createElement('div')
|
| 183 |
+
panel.className = 'pv-detail'
|
| 184 |
+
panel.setAttribute('role', 'dialog')
|
| 185 |
+
panel.setAttribute('aria-label', 'PhilVerify fact-check details')
|
| 186 |
+
|
| 187 |
+
const color = VERDICT_COLORS[result.verdict] ?? '#5c554e'
|
| 188 |
+
const topSource = result.layer2?.sources?.[0]
|
| 189 |
+
|
| 190 |
+
panel.innerHTML = `
|
| 191 |
+
<div class="pv-detail-header">
|
| 192 |
+
<span class="pv-logo">PHIL<span style="color:${color}">VERIFY</span></span>
|
| 193 |
+
<button class="pv-close" aria-label="Close fact-check panel">β</button>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="pv-row">
|
| 196 |
+
<span class="pv-label">VERDICT</span>
|
| 197 |
+
<span class="pv-val" style="color:${color};font-weight:700">${safeText(result.verdict)}</span>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="pv-row">
|
| 200 |
+
<span class="pv-label">SCORE</span>
|
| 201 |
+
<span class="pv-val" style="color:${color}">${Math.round(result.final_score)}%</span>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="pv-row">
|
| 204 |
+
<span class="pv-label">LANGUAGE</span>
|
| 205 |
+
<span class="pv-val">${safeText(result.language ?? 'β')}</span>
|
| 206 |
+
</div>
|
| 207 |
+
${result.layer1?.triggered_features?.length ? `
|
| 208 |
+
<div class="pv-signals">
|
| 209 |
+
<span class="pv-label">SIGNALS</span>
|
| 210 |
+
<div class="pv-tags">
|
| 211 |
+
${result.layer1.triggered_features.slice(0, 3).map(f =>
|
| 212 |
+
`<span class="pv-tag">${safeText(f)}</span>`
|
| 213 |
+
).join('')}
|
| 214 |
+
</div>
|
| 215 |
+
</div>` : ''}
|
| 216 |
+
${topSource ? `
|
| 217 |
+
<div class="pv-source">
|
| 218 |
+
<span class="pv-label">TOP SOURCE</span>
|
| 219 |
+
<a href="${safeUrl(topSource.url)}" target="_blank" rel="noreferrer" class="pv-source-link">
|
| 220 |
+
${safeText(topSource.title?.slice(0, 60) ?? topSource.source_name ?? 'View source')} β
|
| 221 |
+
</a>
|
| 222 |
+
</div>` : ''}
|
| 223 |
+
<a href="http://localhost:5173" target="_blank" rel="noreferrer" class="pv-open-full">
|
| 224 |
+
Open full analysis β
|
| 225 |
+
</a>
|
| 226 |
+
`
|
| 227 |
+
|
| 228 |
+
panel.querySelector('.pv-close').addEventListener('click', (e) => {
|
| 229 |
+
e.stopPropagation()
|
| 230 |
+
panel.remove()
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
badge.insertAdjacentElement('afterend', panel)
|
| 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 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// ββ Loading state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 257 |
+
|
| 258 |
+
function injectLoadingBadge(post) {
|
| 259 |
+
const container = document.createElement('div')
|
| 260 |
+
container.className = 'pv-badge-wrap pv-loading'
|
| 261 |
+
container.setAttribute('aria-label', 'PhilVerify: verifyingβ¦')
|
| 262 |
+
container.innerHTML = `
|
| 263 |
+
<div class="pv-badge pv-badge--loading">
|
| 264 |
+
<span class="pv-spinner" aria-hidden="true"></span>
|
| 265 |
+
<span>Verifyingβ¦</span>
|
| 266 |
+
</div>
|
| 267 |
+
`
|
| 268 |
+
post.appendChild(container)
|
| 269 |
+
return container
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// ββ Post processing βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 273 |
+
|
| 274 |
+
async function processPost(post) {
|
| 275 |
+
if (post.dataset.philverify) return // already processed
|
| 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)
|
| 295 |
+
})
|
| 296 |
+
})
|
| 297 |
+
|
| 298 |
+
loader.remove()
|
| 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)
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// ββ MutationObserver ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 315 |
+
|
| 316 |
+
const pendingPosts = new Set()
|
| 317 |
+
let rafScheduled = false
|
| 318 |
+
|
| 319 |
+
function flushPosts() {
|
| 320 |
+
rafScheduled = false
|
| 321 |
+
for (const post of pendingPosts) processPost(post)
|
| 322 |
+
pendingPosts.clear()
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
function scheduleProcess(post) {
|
| 326 |
+
pendingPosts.add(post)
|
| 327 |
+
if (!rafScheduled) {
|
| 328 |
+
rafScheduled = true
|
| 329 |
+
requestAnimationFrame(flushPosts)
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function findPosts(root) {
|
| 334 |
+
for (const sel of POST_SELECTORS) {
|
| 335 |
+
const found = root.querySelectorAll(sel)
|
| 336 |
+
if (found.length) return found
|
| 337 |
+
}
|
| 338 |
+
return []
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const observer = new MutationObserver((mutations) => {
|
| 342 |
+
for (const mutation of mutations) {
|
| 343 |
+
for (const node of mutation.addedNodes) {
|
| 344 |
+
if (node.nodeType !== 1) continue // element nodes only
|
| 345 |
+
// Check if the node itself matches
|
| 346 |
+
for (const sel of POST_SELECTORS) {
|
| 347 |
+
if (node.matches?.(sel)) { scheduleProcess(node); break }
|
| 348 |
+
}
|
| 349 |
+
// Check descendants
|
| 350 |
+
const posts = findPosts(node)
|
| 351 |
+
for (const post of posts) scheduleProcess(post)
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
// ββ Initialization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 357 |
+
|
| 358 |
+
async function init() {
|
| 359 |
+
// Check autoScan setting before activating
|
| 360 |
+
const response = await new Promise(resolve => {
|
| 361 |
+
chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }, resolve)
|
| 362 |
+
}).catch(() => ({ autoScan: true }))
|
| 363 |
+
|
| 364 |
+
if (!response?.autoScan) return
|
| 365 |
+
|
| 366 |
+
// Process any posts already in the DOM
|
| 367 |
+
const existing = findPosts(document.body)
|
| 368 |
+
for (const post of existing) scheduleProcess(post)
|
| 369 |
+
|
| 370 |
+
// Watch for new posts (Facebook is a SPA β feed dynamically loads more)
|
| 371 |
+
observer.observe(document.body, { childList: true, subtree: true })
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
init()
|
| 375 |
+
|
| 376 |
+
// React to autoScan toggle without requiring page reload
|
| 377 |
+
chrome.storage.onChanged.addListener((changes, area) => {
|
| 378 |
+
if (area !== 'local' || !changes.settings) return
|
| 379 |
+
const autoScan = changes.settings.newValue?.autoScan
|
| 380 |
+
if (autoScan === false) {
|
| 381 |
+
observer.disconnect()
|
| 382 |
+
} else if (autoScan === true) {
|
| 383 |
+
observer.observe(document.body, { childList: true, subtree: true })
|
| 384 |
+
// Process any posts that appeared while scanning was paused
|
| 385 |
+
const existing = findPosts(document.body)
|
| 386 |
+
for (const post of existing) scheduleProcess(post)
|
| 387 |
+
}
|
| 388 |
+
})
|
| 389 |
+
|
| 390 |
+
})()
|
extension/generate_icons.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate PhilVerify extension icons (16Γ16, 32Γ32, 48Γ48, 128Γ128 PNG).
|
| 3 |
+
Requires Pillow: pip install Pillow
|
| 4 |
+
Run from the extension/ directory: python generate_icons.py
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 8 |
+
|
| 9 |
+
SIZES = [16, 32, 48, 128]
|
| 10 |
+
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'icons')
|
| 11 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
BG_COLOR = (13, 13, 13, 255) # --bg-base
|
| 14 |
+
RED_COLOR = (220, 38, 38, 255) # --accent-red
|
| 15 |
+
TEXT_COLOR = (245, 240, 232, 255) # --text-primary
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def make_icon(size: int) -> Image.Image:
|
| 19 |
+
img = Image.new('RGBA', (size, size), BG_COLOR)
|
| 20 |
+
draw = ImageDraw.Draw(img)
|
| 21 |
+
|
| 22 |
+
# Red left-edge accent bar (3px scaled)
|
| 23 |
+
bar_width = max(2, size // 10)
|
| 24 |
+
draw.rectangle([0, 0, bar_width - 1, size - 1], fill=RED_COLOR)
|
| 25 |
+
|
| 26 |
+
# 'PV' text label β only draw text on larger icons where it looks clean
|
| 27 |
+
font_size = max(6, int(size * 0.38))
|
| 28 |
+
font = None
|
| 29 |
+
for path in [
|
| 30 |
+
'/System/Library/Fonts/Helvetica.ttc',
|
| 31 |
+
'/System/Library/Fonts/SFNSDisplay.ttf',
|
| 32 |
+
'/System/Library/Fonts/ArialHB.ttc',
|
| 33 |
+
]:
|
| 34 |
+
try:
|
| 35 |
+
font = ImageFont.truetype(path, font_size)
|
| 36 |
+
break
|
| 37 |
+
except OSError:
|
| 38 |
+
continue
|
| 39 |
+
if font is None:
|
| 40 |
+
font = ImageFont.load_default()
|
| 41 |
+
|
| 42 |
+
if size >= 32:
|
| 43 |
+
text = 'PV'
|
| 44 |
+
try:
|
| 45 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 46 |
+
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 47 |
+
tx = bar_width + (size - bar_width - tw) // 2
|
| 48 |
+
ty = (size - th) // 2 - bbox[1]
|
| 49 |
+
draw.text((tx, ty), text, fill=TEXT_COLOR, font=font)
|
| 50 |
+
except Exception:
|
| 51 |
+
pass # Skip text on render error β icon still has the red bar
|
| 52 |
+
|
| 53 |
+
return img
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
for sz in SIZES:
|
| 57 |
+
icon_path = os.path.join(OUTPUT_DIR, f'icon{sz}.png')
|
| 58 |
+
make_icon(sz).save(icon_path, 'PNG')
|
| 59 |
+
print(f'β icons/icon{sz}.png')
|
| 60 |
+
|
| 61 |
+
print('Icons generated in extension/icons/')
|
extension/icons/icon128.png
ADDED
|
|
extension/icons/icon16.png
ADDED
|
|
extension/icons/icon32.png
ADDED
|
|
extension/icons/icon48.png
ADDED
|
|
extension/manifest.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"manifest_version": 3,
|
| 3 |
+
"name": "PhilVerify",
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"description": "AI-powered fact-checking for Philippine news and social media. Detects misinformation on Facebook in real time.",
|
| 6 |
+
|
| 7 |
+
"permissions": [
|
| 8 |
+
"storage",
|
| 9 |
+
"activeTab",
|
| 10 |
+
"scripting"
|
| 11 |
+
],
|
| 12 |
+
|
| 13 |
+
"host_permissions": [
|
| 14 |
+
"https://www.facebook.com/*",
|
| 15 |
+
"https://facebook.com/*",
|
| 16 |
+
"http://localhost:8000/*",
|
| 17 |
+
"https://api.philverify.com/*"
|
| 18 |
+
],
|
| 19 |
+
|
| 20 |
+
"background": {
|
| 21 |
+
"service_worker": "background.js",
|
| 22 |
+
"type": "module"
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
"content_scripts": [
|
| 26 |
+
{
|
| 27 |
+
"matches": ["https://www.facebook.com/*", "https://facebook.com/*"],
|
| 28 |
+
"js": ["content.js"],
|
| 29 |
+
"css": ["content.css"],
|
| 30 |
+
"run_at": "document_idle"
|
| 31 |
+
}
|
| 32 |
+
],
|
| 33 |
+
|
| 34 |
+
"action": {
|
| 35 |
+
"default_popup": "popup.html",
|
| 36 |
+
"default_title": "PhilVerify β Fact Check",
|
| 37 |
+
"default_icon": {
|
| 38 |
+
"16": "icons/icon16.png",
|
| 39 |
+
"32": "icons/icon32.png",
|
| 40 |
+
"48": "icons/icon48.png",
|
| 41 |
+
"128": "icons/icon128.png"
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
"icons": {
|
| 46 |
+
"16": "icons/icon16.png",
|
| 47 |
+
"32": "icons/icon32.png",
|
| 48 |
+
"48": "icons/icon48.png",
|
| 49 |
+
"128": "icons/icon128.png"
|
| 50 |
+
},
|
| 51 |
+
|
| 52 |
+
"content_security_policy": {
|
| 53 |
+
"extension_pages": "script-src 'self'; object-src 'self'"
|
| 54 |
+
}
|
| 55 |
+
}
|
extension/popup.html
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>PhilVerify</title>
|
| 7 |
+
<style>
|
| 8 |
+
/* ββ Reset βββββββββββββββββββββββββββββββββββββββββ */
|
| 9 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
|
| 11 |
+
/* ββ Design tokens (mirrors dashboard) ββββββββββββ */
|
| 12 |
+
:root {
|
| 13 |
+
--bg-base: #0d0d0d;
|
| 14 |
+
--bg-surface: #141414;
|
| 15 |
+
--bg-elevated: #1c1c1c;
|
| 16 |
+
--bg-hover: #222;
|
| 17 |
+
--border: rgba(245,240,232,0.07);
|
| 18 |
+
--border-light: rgba(245,240,232,0.14);
|
| 19 |
+
--accent-red: #dc2626;
|
| 20 |
+
--accent-gold: #d97706;
|
| 21 |
+
--accent-cyan: #06b6d4;
|
| 22 |
+
--credible: #16a34a;
|
| 23 |
+
--unverified: #d97706;
|
| 24 |
+
--fake: #dc2626;
|
| 25 |
+
--text-primary: #f5f0e8;
|
| 26 |
+
--text-secondary: #a89f94;
|
| 27 |
+
--text-muted: #5c554e;
|
| 28 |
+
--font-display: 'Syne', system-ui, sans-serif;
|
| 29 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
html { color-scheme: dark; }
|
| 33 |
+
|
| 34 |
+
body {
|
| 35 |
+
width: 340px;
|
| 36 |
+
min-height: 200px;
|
| 37 |
+
background: var(--bg-base);
|
| 38 |
+
color: var(--text-primary);
|
| 39 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
| 40 |
+
font-size: 12px;
|
| 41 |
+
-webkit-font-smoothing: antialiased;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* ββ Navbar ββββββββββββββββββββββββββββββββββββββββ */
|
| 45 |
+
.navbar {
|
| 46 |
+
display: flex;
|
| 47 |
+
align-items: center;
|
| 48 |
+
justify-content: space-between;
|
| 49 |
+
padding: 10px 14px;
|
| 50 |
+
background: var(--bg-surface);
|
| 51 |
+
border-bottom: 1px solid var(--border);
|
| 52 |
+
}
|
| 53 |
+
.logo {
|
| 54 |
+
font-weight: 800;
|
| 55 |
+
font-size: 13px;
|
| 56 |
+
letter-spacing: 0.06em;
|
| 57 |
+
}
|
| 58 |
+
.logo span { color: var(--accent-red); }
|
| 59 |
+
|
| 60 |
+
/* ββ Tab bar βββββββββββββββββββββββββββββββββββββββ */
|
| 61 |
+
.tabs {
|
| 62 |
+
display: flex;
|
| 63 |
+
border-bottom: 1px solid var(--border);
|
| 64 |
+
}
|
| 65 |
+
.tab {
|
| 66 |
+
flex: 1;
|
| 67 |
+
padding: 10px 0;
|
| 68 |
+
min-height: 40px;
|
| 69 |
+
background: none;
|
| 70 |
+
border: none;
|
| 71 |
+
color: var(--text-muted);
|
| 72 |
+
font-size: 10px;
|
| 73 |
+
font-weight: 700;
|
| 74 |
+
letter-spacing: 0.1em;
|
| 75 |
+
text-transform: uppercase;
|
| 76 |
+
cursor: pointer;
|
| 77 |
+
border-bottom: 2px solid transparent;
|
| 78 |
+
touch-action: manipulation;
|
| 79 |
+
}
|
| 80 |
+
.tab.active {
|
| 81 |
+
color: var(--text-primary);
|
| 82 |
+
border-bottom-color: var(--accent-red);
|
| 83 |
+
}
|
| 84 |
+
.tab:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
|
| 85 |
+
|
| 86 |
+
/* ββ Panels ββββββββββββββββββββββββββββββββββββββββ */
|
| 87 |
+
.panel { display: none; padding: 12px 14px; }
|
| 88 |
+
.panel.active { display: block; }
|
| 89 |
+
|
| 90 |
+
/* ββ Verify panel ββββββββββββββββββββββββββββββββββ */
|
| 91 |
+
.current-url {
|
| 92 |
+
font-size: 10px;
|
| 93 |
+
color: var(--text-muted);
|
| 94 |
+
white-space: nowrap;
|
| 95 |
+
overflow: hidden;
|
| 96 |
+
text-overflow: ellipsis;
|
| 97 |
+
margin-bottom: 10px;
|
| 98 |
+
font-family: var(--font-mono);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
textarea {
|
| 102 |
+
width: 100%;
|
| 103 |
+
resize: none;
|
| 104 |
+
padding: 8px 10px;
|
| 105 |
+
background: var(--bg-elevated);
|
| 106 |
+
border: 1px solid var(--border);
|
| 107 |
+
color: var(--text-primary);
|
| 108 |
+
font-size: 11px;
|
| 109 |
+
font-family: inherit;
|
| 110 |
+
border-radius: 2px;
|
| 111 |
+
line-height: 1.6;
|
| 112 |
+
margin-bottom: 8px;
|
| 113 |
+
field-sizing: content;
|
| 114 |
+
min-height: 60px;
|
| 115 |
+
}
|
| 116 |
+
textarea:focus-visible {
|
| 117 |
+
outline: none;
|
| 118 |
+
box-shadow: 0 0 0 2px var(--accent-red);
|
| 119 |
+
border-color: var(--accent-red);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.btn-verify {
|
| 123 |
+
width: 100%;
|
| 124 |
+
padding: 8px;
|
| 125 |
+
background: var(--accent-red);
|
| 126 |
+
color: #fff;
|
| 127 |
+
border: none;
|
| 128 |
+
font-size: 10px;
|
| 129 |
+
font-weight: 700;
|
| 130 |
+
letter-spacing: 0.1em;
|
| 131 |
+
text-transform: uppercase;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
border-radius: 2px;
|
| 134 |
+
min-height: 44px;
|
| 135 |
+
touch-action: manipulation;
|
| 136 |
+
}
|
| 137 |
+
.btn-verify:disabled {
|
| 138 |
+
background: var(--bg-elevated);
|
| 139 |
+
color: var(--text-muted);
|
| 140 |
+
cursor: not-allowed;
|
| 141 |
+
}
|
| 142 |
+
.btn-verify:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
|
| 143 |
+
|
| 144 |
+
/* ββ Result card βββββββββββββββββββββββββββββββββββ */
|
| 145 |
+
.result {
|
| 146 |
+
margin-top: 10px;
|
| 147 |
+
padding: 10px 12px;
|
| 148 |
+
background: var(--bg-surface);
|
| 149 |
+
border: 1px solid var(--border);
|
| 150 |
+
border-radius: 3px;
|
| 151 |
+
}
|
| 152 |
+
.result-verdict {
|
| 153 |
+
font-size: 15px;
|
| 154 |
+
font-weight: 800;
|
| 155 |
+
letter-spacing: -0.01em;
|
| 156 |
+
margin-bottom: 4px;
|
| 157 |
+
}
|
| 158 |
+
.result-score {
|
| 159 |
+
font-size: 10px;
|
| 160 |
+
color: var(--text-muted);
|
| 161 |
+
font-family: var(--font-mono);
|
| 162 |
+
}
|
| 163 |
+
.result-row {
|
| 164 |
+
display: flex;
|
| 165 |
+
justify-content: space-between;
|
| 166 |
+
align-items: center;
|
| 167 |
+
padding: 4px 0;
|
| 168 |
+
border-top: 1px solid var(--border);
|
| 169 |
+
margin-top: 4px;
|
| 170 |
+
}
|
| 171 |
+
.result-label {
|
| 172 |
+
font-size: 9px;
|
| 173 |
+
color: var(--text-muted);
|
| 174 |
+
font-weight: 700;
|
| 175 |
+
letter-spacing: 0.1em;
|
| 176 |
+
text-transform: uppercase;
|
| 177 |
+
}
|
| 178 |
+
.result-val {
|
| 179 |
+
font-size: 10px;
|
| 180 |
+
color: var(--text-secondary);
|
| 181 |
+
font-family: var(--font-mono);
|
| 182 |
+
}
|
| 183 |
+
.result-source {
|
| 184 |
+
margin-top: 8px;
|
| 185 |
+
padding-top: 6px;
|
| 186 |
+
border-top: 1px solid var(--border);
|
| 187 |
+
}
|
| 188 |
+
.result-source a {
|
| 189 |
+
color: var(--accent-cyan);
|
| 190 |
+
font-size: 10px;
|
| 191 |
+
text-decoration: none;
|
| 192 |
+
display: block;
|
| 193 |
+
overflow: hidden;
|
| 194 |
+
text-overflow: ellipsis;
|
| 195 |
+
white-space: nowrap;
|
| 196 |
+
}
|
| 197 |
+
.result-source a:hover { text-decoration: underline; }
|
| 198 |
+
.open-full {
|
| 199 |
+
display: block;
|
| 200 |
+
text-align: center;
|
| 201 |
+
margin-top: 8px;
|
| 202 |
+
color: var(--accent-red);
|
| 203 |
+
font-size: 9px;
|
| 204 |
+
font-weight: 700;
|
| 205 |
+
letter-spacing: 0.1em;
|
| 206 |
+
text-transform: uppercase;
|
| 207 |
+
text-decoration: none;
|
| 208 |
+
padding: 4px;
|
| 209 |
+
border: 1px solid rgba(220,38,38,0.3);
|
| 210 |
+
border-radius: 2px;
|
| 211 |
+
}
|
| 212 |
+
.open-full:hover { background: rgba(220,38,38,0.08); }
|
| 213 |
+
|
| 214 |
+
/* ββ Loading / error states ββββββββββββββββββββββββ */
|
| 215 |
+
.state-loading, .state-error, .state-empty {
|
| 216 |
+
padding: 20px;
|
| 217 |
+
text-align: center;
|
| 218 |
+
color: var(--text-muted);
|
| 219 |
+
font-size: 11px;
|
| 220 |
+
}
|
| 221 |
+
.state-error { color: #f87171; }
|
| 222 |
+
.spinner {
|
| 223 |
+
display: inline-block;
|
| 224 |
+
width: 16px; height: 16px;
|
| 225 |
+
border: 2px solid rgba(168,159,148,0.2);
|
| 226 |
+
border-top-color: var(--text-muted);
|
| 227 |
+
border-radius: 50%;
|
| 228 |
+
animation: spin 0.7s linear infinite;
|
| 229 |
+
margin-bottom: 8px;
|
| 230 |
+
}
|
| 231 |
+
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
| 232 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 233 |
+
|
| 234 |
+
/* ββ History panel βββββββββββββββββββββββββββββββββ */
|
| 235 |
+
.history-list { display: flex; flex-direction: column; gap: 6px; }
|
| 236 |
+
.history-item {
|
| 237 |
+
padding: 8px 10px;
|
| 238 |
+
background: var(--bg-surface);
|
| 239 |
+
border: 1px solid var(--border);
|
| 240 |
+
border-radius: 3px;
|
| 241 |
+
cursor: default;
|
| 242 |
+
}
|
| 243 |
+
.history-item-top {
|
| 244 |
+
display: flex;
|
| 245 |
+
justify-content: space-between;
|
| 246 |
+
align-items: center;
|
| 247 |
+
gap: 8px;
|
| 248 |
+
margin-bottom: 3px;
|
| 249 |
+
}
|
| 250 |
+
.history-verdict {
|
| 251 |
+
font-size: 9px;
|
| 252 |
+
font-weight: 700;
|
| 253 |
+
letter-spacing: 0.08em;
|
| 254 |
+
text-transform: uppercase;
|
| 255 |
+
padding: 2px 6px;
|
| 256 |
+
border-radius: 2px;
|
| 257 |
+
flex-shrink: 0;
|
| 258 |
+
}
|
| 259 |
+
.history-score {
|
| 260 |
+
font-size: 10px;
|
| 261 |
+
font-family: var(--font-mono);
|
| 262 |
+
color: var(--text-muted);
|
| 263 |
+
}
|
| 264 |
+
.history-preview {
|
| 265 |
+
font-size: 10px;
|
| 266 |
+
color: var(--text-secondary);
|
| 267 |
+
overflow: hidden;
|
| 268 |
+
text-overflow: ellipsis;
|
| 269 |
+
white-space: nowrap;
|
| 270 |
+
}
|
| 271 |
+
.history-time {
|
| 272 |
+
font-size: 9px;
|
| 273 |
+
color: var(--text-muted);
|
| 274 |
+
margin-top: 2px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ββ Settings panel ββββββββββββββββββββββββββββββββ */
|
| 278 |
+
.setting-row {
|
| 279 |
+
display: flex;
|
| 280 |
+
flex-direction: column;
|
| 281 |
+
gap: 4px;
|
| 282 |
+
margin-bottom: 12px;
|
| 283 |
+
}
|
| 284 |
+
.setting-label {
|
| 285 |
+
font-size: 9px;
|
| 286 |
+
font-weight: 700;
|
| 287 |
+
letter-spacing: 0.1em;
|
| 288 |
+
text-transform: uppercase;
|
| 289 |
+
color: var(--text-muted);
|
| 290 |
+
}
|
| 291 |
+
.setting-input {
|
| 292 |
+
width: 100%;
|
| 293 |
+
padding: 8px 10px;
|
| 294 |
+
min-height: 36px;
|
| 295 |
+
background: var(--bg-elevated);
|
| 296 |
+
border: 1px solid var(--border);
|
| 297 |
+
color: var(--text-primary);
|
| 298 |
+
font-size: 11px;
|
| 299 |
+
font-family: var(--font-mono);
|
| 300 |
+
border-radius: 2px;
|
| 301 |
+
}
|
| 302 |
+
.setting-input:focus-visible {
|
| 303 |
+
outline: none;
|
| 304 |
+
box-shadow: 0 0 0 2px var(--accent-cyan);
|
| 305 |
+
}
|
| 306 |
+
.toggle-row {
|
| 307 |
+
display: flex;
|
| 308 |
+
justify-content: space-between;
|
| 309 |
+
align-items: center;
|
| 310 |
+
margin-bottom: 12px;
|
| 311 |
+
}
|
| 312 |
+
.toggle-label {
|
| 313 |
+
font-size: 11px;
|
| 314 |
+
color: var(--text-secondary);
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
}
|
| 317 |
+
.toggle {
|
| 318 |
+
position: relative;
|
| 319 |
+
width: 32px;
|
| 320 |
+
height: 18px;
|
| 321 |
+
}
|
| 322 |
+
.toggle input { opacity: 0; width: 0; height: 0; }
|
| 323 |
+
.toggle-track {
|
| 324 |
+
position: absolute;
|
| 325 |
+
inset: 0;
|
| 326 |
+
border-radius: 18px;
|
| 327 |
+
background: var(--bg-elevated);
|
| 328 |
+
border: 1px solid var(--border);
|
| 329 |
+
cursor: pointer;
|
| 330 |
+
transition: background 0.2s;
|
| 331 |
+
}
|
| 332 |
+
.toggle input:checked + .toggle-track { background: var(--accent-red); border-color: var(--accent-red); }
|
| 333 |
+
@media (prefers-reduced-motion: reduce) {
|
| 334 |
+
.toggle-track,
|
| 335 |
+
.toggle-track::after { transition: none; }
|
| 336 |
+
}
|
| 337 |
+
.toggle-track::after {
|
| 338 |
+
content: '';
|
| 339 |
+
position: absolute;
|
| 340 |
+
left: 2px; top: 2px;
|
| 341 |
+
width: 12px; height: 12px;
|
| 342 |
+
background: #fff;
|
| 343 |
+
border-radius: 50%;
|
| 344 |
+
transition: transform 0.2s;
|
| 345 |
+
}
|
| 346 |
+
.toggle input:checked + .toggle-track::after { transform: translateX(14px); }
|
| 347 |
+
.btn-save {
|
| 348 |
+
width: 100%;
|
| 349 |
+
padding: 7px;
|
| 350 |
+
min-height: 36px;
|
| 351 |
+
background: var(--bg-elevated);
|
| 352 |
+
border: 1px solid var(--border-light);
|
| 353 |
+
color: var(--text-secondary);
|
| 354 |
+
font-size: 10px;
|
| 355 |
+
font-weight: 700;
|
| 356 |
+
letter-spacing: 0.1em;
|
| 357 |
+
text-transform: uppercase;
|
| 358 |
+
cursor: pointer;
|
| 359 |
+
border-radius: 2px;
|
| 360 |
+
touch-action: manipulation;
|
| 361 |
+
}
|
| 362 |
+
.btn-save:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
|
| 363 |
+
.btn-save:focus-visible { outline: 2px solid var(--accent-cyan); }
|
| 364 |
+
.saved-flash {
|
| 365 |
+
text-align: center;
|
| 366 |
+
color: var(--credible);
|
| 367 |
+
font-size: 10px;
|
| 368 |
+
margin-top: 6px;
|
| 369 |
+
height: 14px;
|
| 370 |
+
}
|
| 371 |
+
</style>
|
| 372 |
+
</head>
|
| 373 |
+
<body>
|
| 374 |
+
|
| 375 |
+
<!-- Navbar -->
|
| 376 |
+
<nav class="navbar" role="banner">
|
| 377 |
+
<div class="logo">PHIL<span>VERIFY</span></div>
|
| 378 |
+
<div id="live-dot" style="display:flex;align-items:center;gap:5px;font-size:9px;color:var(--text-muted);font-weight:700;letter-spacing:0.1em;">
|
| 379 |
+
<span id="api-status-dot" style="width:6px;height:6px;border-radius:50%;background:var(--text-muted);" aria-hidden="true"></span>
|
| 380 |
+
<span id="api-status-label">CHECKINGβ¦</span>
|
| 381 |
+
</div>
|
| 382 |
+
</nav>
|
| 383 |
+
|
| 384 |
+
<!-- Tabs -->
|
| 385 |
+
<div class="tabs" role="tablist" aria-label="PhilVerify sections">
|
| 386 |
+
<button class="tab active" role="tab" aria-selected="true" aria-controls="panel-verify" id="tab-verify" data-tab="verify">Verify</button>
|
| 387 |
+
<button class="tab" role="tab" aria-selected="false" aria-controls="panel-history" id="tab-history" data-tab="history">History</button>
|
| 388 |
+
<button class="tab" role="tab" aria-selected="false" aria-controls="panel-settings" id="tab-settings" data-tab="settings">Settings</button>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<!-- ββ Verify Panel ββ -->
|
| 392 |
+
<div class="panel active" id="panel-verify" role="tabpanel" aria-labelledby="tab-verify">
|
| 393 |
+
<p class="current-url" id="current-url" title="">Loading current URLβ¦</p>
|
| 394 |
+
<textarea
|
| 395 |
+
id="verify-input"
|
| 396 |
+
name="claim-text"
|
| 397 |
+
placeholder="Paste text or URL to fact-checkβ¦"
|
| 398 |
+
rows="3"
|
| 399 |
+
aria-label="Text or URL to verify"
|
| 400 |
+
autocomplete="off"
|
| 401 |
+
spellcheck="true"
|
| 402 |
+
></textarea>
|
| 403 |
+
<button class="btn-verify" id="btn-verify" type="button" aria-busy="false">
|
| 404 |
+
Verify Claim
|
| 405 |
+
</button>
|
| 406 |
+
<div id="verify-result" aria-live="polite" aria-atomic="true"></div>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<!-- ββ History Panel ββ -->
|
| 410 |
+
<div class="panel" id="panel-history" role="tabpanel" aria-labelledby="tab-history">
|
| 411 |
+
<div id="history-container">
|
| 412 |
+
<div class="state-empty">No verifications yet β use the Verify tab or browse Facebook.</div>
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
<!-- ββ Settings Panel ββ -->
|
| 417 |
+
<div class="panel" id="panel-settings" role="tabpanel" aria-labelledby="tab-settings">
|
| 418 |
+
<div class="setting-row">
|
| 419 |
+
<label class="setting-label" for="api-base">Backend API URL</label>
|
| 420 |
+
<input
|
| 421 |
+
class="setting-input"
|
| 422 |
+
id="api-base"
|
| 423 |
+
name="api-base"
|
| 424 |
+
type="url"
|
| 425 |
+
autocomplete="url"
|
| 426 |
+
placeholder="http://localhost:8000"
|
| 427 |
+
aria-describedby="api-base-hint"
|
| 428 |
+
>
|
| 429 |
+
<span id="api-base-hint" style="font-size:9px;color:var(--text-muted);">
|
| 430 |
+
Default: http://localhost:8000 β change for production deployment.
|
| 431 |
+
</span>
|
| 432 |
+
</div>
|
| 433 |
+
<div class="toggle-row">
|
| 434 |
+
<label for="auto-scan" class="toggle-label">Auto-scan Facebook feed</label>
|
| 435 |
+
<label class="toggle">
|
| 436 |
+
<input type="checkbox" id="auto-scan" name="auto-scan" checked>
|
| 437 |
+
<span class="toggle-track" aria-hidden="true"></span>
|
| 438 |
+
</label>
|
| 439 |
+
</div>
|
| 440 |
+
<button class="btn-save" id="btn-save" type="button">Save Settings</button>
|
| 441 |
+
<div class="saved-flash" id="saved-flash" aria-live="polite"></div>
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
<script src="popup.js"></script>
|
| 445 |
+
</body>
|
| 446 |
+
</html>
|
extension/popup.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PhilVerify β Popup Script
|
| 3 |
+
* Controls the extension popup: verify tab, history tab, settings tab.
|
| 4 |
+
*/
|
| 5 |
+
'use strict'
|
| 6 |
+
|
| 7 |
+
// ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
const VERDICT_COLORS = {
|
| 10 |
+
'Credible': '#16a34a',
|
| 11 |
+
'Unverified': '#d97706',
|
| 12 |
+
'Likely Fake': '#dc2626',
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
/** Escape HTML special chars to prevent XSS in innerHTML templates */
|
| 17 |
+
function safeText(str) {
|
| 18 |
+
if (str == null) return ''
|
| 19 |
+
return String(str)
|
| 20 |
+
.replace(/&/g, '&')
|
| 21 |
+
.replace(/</g, '<')
|
| 22 |
+
.replace(/>/g, '>')
|
| 23 |
+
.replace(/"/g, '"')
|
| 24 |
+
.replace(/'/g, ''')
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/** Allow only http/https URLs; return '#' for anything else */
|
| 28 |
+
function safeUrl(url) {
|
| 29 |
+
if (!url) return '#'
|
| 30 |
+
try {
|
| 31 |
+
const u = new URL(url)
|
| 32 |
+
return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '#'
|
| 33 |
+
} catch { return '#' }
|
| 34 |
+
}
|
| 35 |
+
function msg(obj) {
|
| 36 |
+
return new Promise(resolve => {
|
| 37 |
+
chrome.runtime.sendMessage(obj, resolve)
|
| 38 |
+
})
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function timeAgo(iso) {
|
| 42 |
+
const diff = Date.now() - new Date(iso).getTime()
|
| 43 |
+
if (diff < 60_000) return 'just now'
|
| 44 |
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
| 45 |
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
| 46 |
+
return `${Math.floor(diff / 86_400_000)}d ago`
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function isUrl(s) {
|
| 50 |
+
try { new URL(s); return s.startsWith('http'); } catch { return false }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// ββ Render helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
|
| 55 |
+
function renderResult(result, container) {
|
| 56 |
+
const color = VERDICT_COLORS[result.verdict] ?? '#5c554e'
|
| 57 |
+
const topSource = result.layer2?.sources?.[0]
|
| 58 |
+
|
| 59 |
+
container.innerHTML = `
|
| 60 |
+
<div class="result" role="status" aria-live="polite">
|
| 61 |
+
<div class="result-verdict" style="color:${color}">${safeText(result.verdict)}</div>
|
| 62 |
+
<div class="result-score">${Math.round(result.final_score)}% credibility${result._fromCache ? ' (cached)' : ''}</div>
|
| 63 |
+
<div class="result-row">
|
| 64 |
+
<span class="result-label">Language</span>
|
| 65 |
+
<span class="result-val">${safeText(result.language ?? 'β')}</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="result-row">
|
| 68 |
+
<span class="result-label">Confidence</span>
|
| 69 |
+
<span class="result-val" style="color:${color}">${result.confidence?.toFixed(1)}%</span>
|
| 70 |
+
</div>
|
| 71 |
+
${result.layer1?.triggered_features?.length ? `
|
| 72 |
+
<div class="result-row">
|
| 73 |
+
<span class="result-label">Signals</span>
|
| 74 |
+
<span class="result-val">${result.layer1.triggered_features.slice(0, 3).map(safeText).join(', ')}</span>
|
| 75 |
+
</div>` : ''}
|
| 76 |
+
${topSource ? `
|
| 77 |
+
<div class="result-source">
|
| 78 |
+
<div class="result-label" style="margin-bottom:4px;">Top Source</div>
|
| 79 |
+
<a href="${safeUrl(topSource.url)}" target="_blank" rel="noreferrer">${safeText(topSource.title?.slice(0, 55) ?? topSource.source_name ?? 'View')} β</a>
|
| 80 |
+
</div>` : ''}
|
| 81 |
+
<a class="open-full" href="http://localhost:5173" target="_blank" rel="noreferrer">
|
| 82 |
+
Open Full Dashboard β
|
| 83 |
+
</a>
|
| 84 |
+
</div>
|
| 85 |
+
`
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function renderHistory(entries, container) {
|
| 89 |
+
if (!entries.length) {
|
| 90 |
+
container.innerHTML = '<div class="state-empty">No verifications yet.</div>'
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
container.innerHTML = `
|
| 94 |
+
<ul class="history-list" role="list" aria-label="Verification history">
|
| 95 |
+
${entries.map(e => {
|
| 96 |
+
const color = VERDICT_COLORS[e.verdict] ?? '#5c554e'
|
| 97 |
+
return `
|
| 98 |
+
<li class="history-item" role="listitem">
|
| 99 |
+
<div class="history-item-top">
|
| 100 |
+
<span class="history-verdict" style="background:${color}22;color:${color};border:1px solid ${color}4d;">${safeText(e.verdict)}</span>
|
| 101 |
+
<span class="history-score">${Math.round(e.final_score)}%</span>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="history-preview">${safeText(e.text_preview || 'β')}</div>
|
| 104 |
+
<div class="history-time">${timeAgo(e.timestamp)}</div>
|
| 105 |
+
</li>`
|
| 106 |
+
}).join('')}
|
| 107 |
+
</ul>
|
| 108 |
+
`
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ββ Tab switching βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 114 |
+
tab.addEventListener('click', () => {
|
| 115 |
+
document.querySelectorAll('.tab').forEach(t => {
|
| 116 |
+
t.classList.remove('active')
|
| 117 |
+
t.setAttribute('aria-selected', 'false')
|
| 118 |
+
})
|
| 119 |
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'))
|
| 120 |
+
tab.classList.add('active')
|
| 121 |
+
tab.setAttribute('aria-selected', 'true')
|
| 122 |
+
document.getElementById(`panel-${tab.dataset.tab}`)?.classList.add('active')
|
| 123 |
+
if (tab.dataset.tab === 'history') loadHistory()
|
| 124 |
+
if (tab.dataset.tab === 'settings') loadSettings()
|
| 125 |
+
})
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
// ββ Verify tab ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
const verifyInput = document.getElementById('verify-input')
|
| 131 |
+
const btnVerify = document.getElementById('btn-verify')
|
| 132 |
+
const verifyResult = document.getElementById('verify-result')
|
| 133 |
+
const currentUrlEl = document.getElementById('current-url')
|
| 134 |
+
|
| 135 |
+
// Auto-populate input with current tab URL if it's a news article
|
| 136 |
+
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
|
| 137 |
+
const url = tab?.url ?? ''
|
| 138 |
+
if (url && !url.startsWith('chrome') && !url.includes('facebook.com')) {
|
| 139 |
+
currentUrlEl.textContent = url
|
| 140 |
+
currentUrlEl.title = url
|
| 141 |
+
verifyInput.value = url
|
| 142 |
+
} else {
|
| 143 |
+
currentUrlEl.textContent = 'facebook.com β use text input below'
|
| 144 |
+
}
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
btnVerify.addEventListener('click', async () => {
|
| 148 |
+
const raw = verifyInput.value.trim()
|
| 149 |
+
if (!raw) return
|
| 150 |
+
|
| 151 |
+
btnVerify.disabled = true
|
| 152 |
+
btnVerify.setAttribute('aria-busy', 'true')
|
| 153 |
+
btnVerify.textContent = 'Verifyingβ¦'
|
| 154 |
+
verifyResult.innerHTML = `
|
| 155 |
+
<div class="state-loading" aria-live="polite">
|
| 156 |
+
<div class="spinner" aria-hidden="true"></div><br>Analyzing claimβ¦
|
| 157 |
+
</div>`
|
| 158 |
+
|
| 159 |
+
const type = isUrl(raw) ? 'VERIFY_URL' : 'VERIFY_TEXT'
|
| 160 |
+
const payload = type === 'VERIFY_URL' ? { type, url: raw } : { type, text: raw }
|
| 161 |
+
const resp = await msg(payload)
|
| 162 |
+
|
| 163 |
+
btnVerify.disabled = false
|
| 164 |
+
btnVerify.setAttribute('aria-busy', 'false')
|
| 165 |
+
btnVerify.textContent = 'Verify Claim'
|
| 166 |
+
|
| 167 |
+
if (resp?.ok) {
|
| 168 |
+
renderResult(resp.result, verifyResult)
|
| 169 |
+
} else {
|
| 170 |
+
verifyResult.innerHTML = `
|
| 171 |
+
<div class="state-error" role="alert">
|
| 172 |
+
${resp?.error ?? 'Could not reach PhilVerify backend.'}<br>
|
| 173 |
+
<span style="font-size:10px;color:var(--text-muted)">Is the backend running at your configured API URL?</span>
|
| 174 |
+
</div>`
|
| 175 |
+
}
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
// Allow Enter (single line) to trigger verify when text area is focused on Ctrl+Enter
|
| 179 |
+
verifyInput.addEventListener('keydown', e => {
|
| 180 |
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| 181 |
+
e.preventDefault()
|
| 182 |
+
btnVerify.click()
|
| 183 |
+
}
|
| 184 |
+
})
|
| 185 |
+
|
| 186 |
+
// ββ History tab βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
|
| 188 |
+
async function loadHistory() {
|
| 189 |
+
const container = document.getElementById('history-container')
|
| 190 |
+
container.innerHTML = '<div class="state-loading"><div class="spinner"></div><br>Loadingβ¦</div>'
|
| 191 |
+
const resp = await msg({ type: 'GET_HISTORY' })
|
| 192 |
+
renderHistory(resp?.history ?? [], container)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// ββ Settings tab ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
+
|
| 197 |
+
async function loadSettings() {
|
| 198 |
+
const resp = await msg({ type: 'GET_SETTINGS' })
|
| 199 |
+
if (!resp) return
|
| 200 |
+
document.getElementById('api-base').value = resp.apiBase ?? 'http://localhost:8000'
|
| 201 |
+
document.getElementById('auto-scan').checked = resp.autoScan ?? true
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
document.getElementById('btn-save').addEventListener('click', async () => {
|
| 205 |
+
const settings = {
|
| 206 |
+
apiBase: document.getElementById('api-base').value.trim() || 'http://localhost:8000',
|
| 207 |
+
autoScan: document.getElementById('auto-scan').checked,
|
| 208 |
+
}
|
| 209 |
+
await msg({ type: 'SAVE_SETTINGS', settings })
|
| 210 |
+
|
| 211 |
+
const flash = document.getElementById('saved-flash')
|
| 212 |
+
flash.textContent = 'Saved β'
|
| 213 |
+
setTimeout(() => { flash.textContent = '' }, 2000)
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
// ββ API status check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
|
| 218 |
+
async function checkApiStatus() {
|
| 219 |
+
const dot = document.getElementById('api-status-dot')
|
| 220 |
+
const label = document.getElementById('api-status-label')
|
| 221 |
+
try {
|
| 222 |
+
const { apiBase } = await msg({ type: 'GET_SETTINGS' })
|
| 223 |
+
const res = await fetch(`${apiBase ?? 'http://localhost:8000'}/health`, { signal: AbortSignal.timeout(3000) })
|
| 224 |
+
if (res.ok) {
|
| 225 |
+
dot.style.background = 'var(--credible)'
|
| 226 |
+
label.style.color = 'var(--credible)'
|
| 227 |
+
label.textContent = 'ONLINE'
|
| 228 |
+
} else {
|
| 229 |
+
throw new Error(`${res.status}`)
|
| 230 |
+
}
|
| 231 |
+
} catch {
|
| 232 |
+
dot.style.background = 'var(--fake)'
|
| 233 |
+
label.style.color = 'var(--fake)'
|
| 234 |
+
label.textContent = 'OFFLINE'
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
checkApiStatus()
|
firebase.json
CHANGED
|
@@ -4,7 +4,6 @@
|
|
| 4 |
"indexes": "firestore.indexes.json"
|
| 5 |
},
|
| 6 |
"hosting": {
|
| 7 |
-
"site": "philverify",
|
| 8 |
"public": "frontend/dist",
|
| 9 |
"ignore": [
|
| 10 |
"firebase.json",
|
|
|
|
| 4 |
"indexes": "firestore.indexes.json"
|
| 5 |
},
|
| 6 |
"hosting": {
|
|
|
|
| 7 |
"public": "frontend/dist",
|
| 8 |
"ignore": [
|
| 9 |
"firebase.json",
|
firebase_client.py
CHANGED
|
@@ -42,8 +42,8 @@ def get_firestore():
|
|
| 42 |
cred = credentials.Certificate(str(_SERVICEACCOUNT_PATH))
|
| 43 |
firebase_admin.initialize_app(cred)
|
| 44 |
logger.info("Firebase initialized via service account key")
|
| 45 |
-
elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS"):
|
| 46 |
-
# Cloud Run
|
| 47 |
cred = credentials.ApplicationDefault()
|
| 48 |
firebase_admin.initialize_app(cred)
|
| 49 |
logger.info("Firebase initialized via Application Default Credentials")
|
|
@@ -92,12 +92,13 @@ async def get_verifications(
|
|
| 92 |
if db is None:
|
| 93 |
return []
|
| 94 |
try:
|
|
|
|
| 95 |
query = (
|
| 96 |
db.collection("verifications")
|
| 97 |
.order_by("timestamp", direction="DESCENDING")
|
| 98 |
)
|
| 99 |
if verdict_filter:
|
| 100 |
-
query = query.where("verdict", "==", verdict_filter)
|
| 101 |
docs = query.limit(limit + offset).stream()
|
| 102 |
results = [doc.to_dict() for doc in docs]
|
| 103 |
return results[offset : offset + limit]
|
|
@@ -106,15 +107,34 @@ async def get_verifications(
|
|
| 106 |
return []
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
async def get_verification_count(verdict_filter: str | None = None) -> int:
|
| 110 |
"""Return total count of verifications (with optional verdict filter)."""
|
| 111 |
db = get_firestore()
|
| 112 |
if db is None:
|
| 113 |
return 0
|
| 114 |
try:
|
|
|
|
| 115 |
query = db.collection("verifications")
|
| 116 |
if verdict_filter:
|
| 117 |
-
query = query.where("verdict", "==", verdict_filter)
|
| 118 |
# Use aggregation query (Firestore native count)
|
| 119 |
result = query.count().get()
|
| 120 |
return result[0][0].value
|
|
|
|
| 42 |
cred = credentials.Certificate(str(_SERVICEACCOUNT_PATH))
|
| 43 |
firebase_admin.initialize_app(cred)
|
| 44 |
logger.info("Firebase initialized via service account key")
|
| 45 |
+
elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or os.getenv("K_SERVICE"):
|
| 46 |
+
# Cloud Run (K_SERVICE is always set) or explicit ADC path
|
| 47 |
cred = credentials.ApplicationDefault()
|
| 48 |
firebase_admin.initialize_app(cred)
|
| 49 |
logger.info("Firebase initialized via Application Default Credentials")
|
|
|
|
| 92 |
if db is None:
|
| 93 |
return []
|
| 94 |
try:
|
| 95 |
+
from google.cloud.firestore_v1.base_query import FieldFilter
|
| 96 |
query = (
|
| 97 |
db.collection("verifications")
|
| 98 |
.order_by("timestamp", direction="DESCENDING")
|
| 99 |
)
|
| 100 |
if verdict_filter:
|
| 101 |
+
query = query.where(filter=FieldFilter("verdict", "==", verdict_filter))
|
| 102 |
docs = query.limit(limit + offset).stream()
|
| 103 |
results = [doc.to_dict() for doc in docs]
|
| 104 |
return results[offset : offset + limit]
|
|
|
|
| 107 |
return []
|
| 108 |
|
| 109 |
|
| 110 |
+
def get_all_verifications_sync() -> list[dict]:
|
| 111 |
+
"""Synchronously fetch ALL verification records from Firestore (used by trends aggregation)."""
|
| 112 |
+
db = get_firestore()
|
| 113 |
+
if db is None:
|
| 114 |
+
return []
|
| 115 |
+
try:
|
| 116 |
+
docs = (
|
| 117 |
+
db.collection("verifications")
|
| 118 |
+
.order_by("timestamp", direction="DESCENDING")
|
| 119 |
+
.limit(10_000) # hard cap β more than enough for trends analysis
|
| 120 |
+
.stream()
|
| 121 |
+
)
|
| 122 |
+
return [doc.to_dict() for doc in docs]
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error("Firestore get_all_verifications_sync error: %s", e)
|
| 125 |
+
return []
|
| 126 |
+
|
| 127 |
+
|
| 128 |
async def get_verification_count(verdict_filter: str | None = None) -> int:
|
| 129 |
"""Return total count of verifications (with optional verdict filter)."""
|
| 130 |
db = get_firestore()
|
| 131 |
if db is None:
|
| 132 |
return 0
|
| 133 |
try:
|
| 134 |
+
from google.cloud.firestore_v1.base_query import FieldFilter
|
| 135 |
query = db.collection("verifications")
|
| 136 |
if verdict_filter:
|
| 137 |
+
query = query.where(filter=FieldFilter("verdict", "==", verdict_filter))
|
| 138 |
# Use aggregation query (Firestore native count)
|
| 139 |
result = query.count().get()
|
| 140 |
return result[0][0].value
|
firestore.indexes.json
CHANGED
|
@@ -1,51 +1,13 @@
|
|
| 1 |
{
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
// },
|
| 13 |
-
//
|
| 14 |
-
// "fieldOverrides": [
|
| 15 |
-
// {
|
| 16 |
-
// "collectionGroup": "widgets",
|
| 17 |
-
// "fieldPath": "baz",
|
| 18 |
-
// "indexes": [
|
| 19 |
-
// { "order": "ASCENDING", "queryScope": "COLLECTION" }
|
| 20 |
-
// ]
|
| 21 |
-
// },
|
| 22 |
-
// ]
|
| 23 |
-
// ]
|
| 24 |
-
//
|
| 25 |
-
// Example (Enterprise Edition):
|
| 26 |
-
//
|
| 27 |
-
// "indexes": [
|
| 28 |
-
// {
|
| 29 |
-
// "collectionGroup": "reviews",
|
| 30 |
-
// "queryScope": "COLLECTION_GROUP",
|
| 31 |
-
// "apiScope": "MONGODB_COMPATIBLE_API",
|
| 32 |
-
// "density": "DENSE",
|
| 33 |
-
// "multikey": false,
|
| 34 |
-
// "fields": [
|
| 35 |
-
// { "fieldPath": "baz", "mode": "ASCENDING" }
|
| 36 |
-
// ]
|
| 37 |
-
// },
|
| 38 |
-
// {
|
| 39 |
-
// "collectionGroup": "items",
|
| 40 |
-
// "queryScope": "COLLECTION_GROUP",
|
| 41 |
-
// "apiScope": "MONGODB_COMPATIBLE_API",
|
| 42 |
-
// "density": "SPARSE_ANY",
|
| 43 |
-
// "multikey": true,
|
| 44 |
-
// "fields": [
|
| 45 |
-
// { "fieldPath": "baz", "mode": "ASCENDING" }
|
| 46 |
-
// ]
|
| 47 |
-
// },
|
| 48 |
-
// ]
|
| 49 |
-
"indexes": [],
|
| 50 |
"fieldOverrides": []
|
| 51 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"indexes": [
|
| 3 |
+
{
|
| 4 |
+
"collectionGroup": "verifications",
|
| 5 |
+
"queryScope": "COLLECTION",
|
| 6 |
+
"fields": [
|
| 7 |
+
{ "fieldPath": "verdict", "order": "ASCENDING" },
|
| 8 |
+
{ "fieldPath": "timestamp", "order": "DESCENDING" }
|
| 9 |
+
]
|
| 10 |
+
}
|
| 11 |
+
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"fieldOverrides": []
|
| 13 |
+
}
|
frontend/index.html
CHANGED
|
@@ -2,9 +2,13 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="theme-color" content="#0d0d0d" />
|
| 8 |
+
<meta name="description" content="PhilVerify β AI-powered fake news detection for Philippine content. Verify text, URLs, images, and video in Tagalog, English, and Taglish." />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
+
<title>PhilVerify β Philippine Fake News Detector</title>
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<div id="root"></div>
|
frontend/package-lock.json
CHANGED
|
@@ -27,6 +27,7 @@
|
|
| 27 |
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 29 |
"globals": "^16.5.0",
|
|
|
|
| 30 |
"vite": "^7.3.1"
|
| 31 |
}
|
| 32 |
},
|
|
@@ -4595,6 +4596,20 @@
|
|
| 4595 |
"node": ">= 0.8.0"
|
| 4596 |
}
|
| 4597 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4598 |
"node_modules/undici-types": {
|
| 4599 |
"version": "7.18.2",
|
| 4600 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
|
|
|
| 27 |
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 29 |
"globals": "^16.5.0",
|
| 30 |
+
"typescript": "^5.9.3",
|
| 31 |
"vite": "^7.3.1"
|
| 32 |
}
|
| 33 |
},
|
|
|
|
| 4596 |
"node": ">= 0.8.0"
|
| 4597 |
}
|
| 4598 |
},
|
| 4599 |
+
"node_modules/typescript": {
|
| 4600 |
+
"version": "5.9.3",
|
| 4601 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 4602 |
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 4603 |
+
"dev": true,
|
| 4604 |
+
"license": "Apache-2.0",
|
| 4605 |
+
"bin": {
|
| 4606 |
+
"tsc": "bin/tsc",
|
| 4607 |
+
"tsserver": "bin/tsserver"
|
| 4608 |
+
},
|
| 4609 |
+
"engines": {
|
| 4610 |
+
"node": ">=14.17"
|
| 4611 |
+
}
|
| 4612 |
+
},
|
| 4613 |
"node_modules/undici-types": {
|
| 4614 |
"version": "7.18.2",
|
| 4615 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
frontend/package.json
CHANGED
|
@@ -7,7 +7,8 @@
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"lint": "eslint .",
|
| 10 |
-
"preview": "vite preview"
|
|
|
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
"@tailwindcss/vite": "^4.2.1",
|
|
@@ -29,6 +30,7 @@
|
|
| 29 |
"eslint-plugin-react-hooks": "^7.0.1",
|
| 30 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 31 |
"globals": "^16.5.0",
|
|
|
|
| 32 |
"vite": "^7.3.1"
|
| 33 |
}
|
| 34 |
}
|
|
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview",
|
| 11 |
+
"typecheck": "tsc --noEmit"
|
| 12 |
},
|
| 13 |
"dependencies": {
|
| 14 |
"@tailwindcss/vite": "^4.2.1",
|
|
|
|
| 30 |
"eslint-plugin-react-hooks": "^7.0.1",
|
| 31 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 32 |
"globals": "^16.5.0",
|
| 33 |
+
"typescript": "^5.9.3",
|
| 34 |
"vite": "^7.3.1"
|
| 35 |
}
|
| 36 |
}
|
frontend/public/logo.svg
ADDED
|
|
frontend/src/App.jsx
CHANGED
|
@@ -4,18 +4,49 @@ import VerifyPage from './pages/VerifyPage.jsx'
|
|
| 4 |
import HistoryPage from './pages/HistoryPage.jsx'
|
| 5 |
import TrendsPage from './pages/TrendsPage.jsx'
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
export default function App() {
|
| 8 |
return (
|
| 9 |
<BrowserRouter>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
<div style={{ minHeight: '100vh', background: 'var(--bg-base)' }}>
|
| 11 |
<Navbar />
|
| 12 |
-
<main>
|
| 13 |
<Routes>
|
| 14 |
<Route path="/" element={<VerifyPage />} />
|
| 15 |
<Route path="/history" element={<HistoryPage />} />
|
| 16 |
<Route path="/trends" element={<TrendsPage />} />
|
| 17 |
</Routes>
|
| 18 |
-
</
|
| 19 |
</div>
|
| 20 |
</BrowserRouter>
|
| 21 |
)
|
|
|
|
| 4 |
import HistoryPage from './pages/HistoryPage.jsx'
|
| 5 |
import TrendsPage from './pages/TrendsPage.jsx'
|
| 6 |
|
| 7 |
+
/** Shared horizontal constraint β all pages + navbar use this */
|
| 8 |
+
export const PAGE_MAX_W = 960
|
| 9 |
+
export const PAGE_STYLE = {
|
| 10 |
+
maxWidth: PAGE_MAX_W,
|
| 11 |
+
width: '100%',
|
| 12 |
+
margin: '0 auto',
|
| 13 |
+
padding: '0 24px',
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
export default function App() {
|
| 17 |
return (
|
| 18 |
<BrowserRouter>
|
| 19 |
+
{/* web-design-guidelines: skip link for keyboard/screen-reader users */}
|
| 20 |
+
<a
|
| 21 |
+
href="#main-content"
|
| 22 |
+
className="sr-only focus-visible:not-sr-only"
|
| 23 |
+
style={{
|
| 24 |
+
position: 'fixed',
|
| 25 |
+
top: 8,
|
| 26 |
+
left: 8,
|
| 27 |
+
zIndex: 9999,
|
| 28 |
+
background: 'var(--accent-red)',
|
| 29 |
+
color: '#fff',
|
| 30 |
+
padding: '8px 16px',
|
| 31 |
+
fontFamily: 'var(--font-display)',
|
| 32 |
+
fontSize: 12,
|
| 33 |
+
fontWeight: 700,
|
| 34 |
+
letterSpacing: '0.08em',
|
| 35 |
+
borderRadius: 2,
|
| 36 |
+
textDecoration: 'none',
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
Skip to content
|
| 40 |
+
</a>
|
| 41 |
<div style={{ minHeight: '100vh', background: 'var(--bg-base)' }}>
|
| 42 |
<Navbar />
|
| 43 |
+
<div id="main-content">
|
| 44 |
<Routes>
|
| 45 |
<Route path="/" element={<VerifyPage />} />
|
| 46 |
<Route path="/history" element={<HistoryPage />} />
|
| 47 |
<Route path="/trends" element={<TrendsPage />} />
|
| 48 |
</Routes>
|
| 49 |
+
</div>
|
| 50 |
</div>
|
| 51 |
</BrowserRouter>
|
| 52 |
)
|
frontend/src/api.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
| 1 |
/** PhilVerify API client β proxied through Vite to http://localhost:8000 */
|
| 2 |
const BASE = '/api'
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
async function post(path, body) {
|
| 5 |
const res = await fetch(`${BASE}${path}`, {
|
| 6 |
method: 'POST',
|
|
@@ -9,7 +19,9 @@ async function post(path, body) {
|
|
| 9 |
})
|
| 10 |
if (!res.ok) {
|
| 11 |
const err = await res.json().catch(() => ({}))
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
return res.json()
|
| 15 |
}
|
|
@@ -18,7 +30,7 @@ async function postForm(path, formData) {
|
|
| 18 |
const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
|
| 19 |
if (!res.ok) {
|
| 20 |
const err = await res.json().catch(() => ({}))
|
| 21 |
-
throw new Error(err.detail
|
| 22 |
}
|
| 23 |
return res.json()
|
| 24 |
}
|
|
@@ -26,8 +38,13 @@ async function postForm(path, formData) {
|
|
| 26 |
async function get(path, params = {}) {
|
| 27 |
const qs = new URLSearchParams(params).toString()
|
| 28 |
const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
|
| 29 |
-
if (!res.ok)
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export const api = {
|
|
@@ -36,6 +53,8 @@ export const api = {
|
|
| 36 |
verifyImage: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/image', f) },
|
| 37 |
verifyVideo: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/video', f) },
|
| 38 |
history: (params) => get('/history', params),
|
|
|
|
| 39 |
trends: () => get('/trends'),
|
| 40 |
health: () => get('/health'),
|
|
|
|
| 41 |
}
|
|
|
|
| 1 |
/** PhilVerify API client β proxied through Vite to http://localhost:8000 */
|
| 2 |
const BASE = '/api'
|
| 3 |
|
| 4 |
+
function _detailToString(detail, status) {
|
| 5 |
+
if (!detail) return `HTTP ${status}`
|
| 6 |
+
if (typeof detail === 'string') return detail
|
| 7 |
+
if (Array.isArray(detail)) {
|
| 8 |
+
// FastAPI validation errors: [{loc, msg, type}, ...]
|
| 9 |
+
return detail.map(d => d.msg || JSON.stringify(d)).join('; ')
|
| 10 |
+
}
|
| 11 |
+
return JSON.stringify(detail)
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
async function post(path, body) {
|
| 15 |
const res = await fetch(`${BASE}${path}`, {
|
| 16 |
method: 'POST',
|
|
|
|
| 19 |
})
|
| 20 |
if (!res.ok) {
|
| 21 |
const err = await res.json().catch(() => ({}))
|
| 22 |
+
const e = new Error(_detailToString(err.detail, res.status))
|
| 23 |
+
e.isBackendError = true // backend responded β not a connection failure
|
| 24 |
+
throw e
|
| 25 |
}
|
| 26 |
return res.json()
|
| 27 |
}
|
|
|
|
| 30 |
const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
|
| 31 |
if (!res.ok) {
|
| 32 |
const err = await res.json().catch(() => ({}))
|
| 33 |
+
throw new Error(_detailToString(err.detail, res.status))
|
| 34 |
}
|
| 35 |
return res.json()
|
| 36 |
}
|
|
|
|
| 38 |
async function get(path, params = {}) {
|
| 39 |
const qs = new URLSearchParams(params).toString()
|
| 40 |
const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
|
| 41 |
+
if (!res.ok) {
|
| 42 |
+
const err = await res.json().catch(() => ({}))
|
| 43 |
+
throw new Error(_detailToString(err.detail, res.status))
|
| 44 |
+
}
|
| 45 |
+
return res.json().catch(() => {
|
| 46 |
+
throw new Error('API returned an unexpected response β the server may be starting up. Please try again.')
|
| 47 |
+
})
|
| 48 |
}
|
| 49 |
|
| 50 |
export const api = {
|
|
|
|
| 53 |
verifyImage: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/image', f) },
|
| 54 |
verifyVideo: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/video', f) },
|
| 55 |
history: (params) => get('/history', params),
|
| 56 |
+
historyDetail: (id) => get(`/history/${id}`),
|
| 57 |
trends: () => get('/trends'),
|
| 58 |
health: () => get('/health'),
|
| 59 |
+
preview: (url) => get('/preview', { url }),
|
| 60 |
}
|
frontend/src/api.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PhilVerify API client β proxied through Vite to http://localhost:8000
|
| 3 |
+
* Typed via src/types.ts which mirrors api/schemas.py
|
| 4 |
+
*/
|
| 5 |
+
import type {
|
| 6 |
+
VerificationResponse,
|
| 7 |
+
HistoryParams,
|
| 8 |
+
HistoryResponse,
|
| 9 |
+
TrendsResponse,
|
| 10 |
+
HealthResponse,
|
| 11 |
+
ApiError as ApiErrorType,
|
| 12 |
+
} from './types'
|
| 13 |
+
import { ApiError } from './types'
|
| 14 |
+
|
| 15 |
+
const BASE = '/api'
|
| 16 |
+
|
| 17 |
+
// ββ Internal fetch helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
async function post<T>(path: string, body: unknown): Promise<T> {
|
| 20 |
+
const res = await fetch(`${BASE}${path}`, {
|
| 21 |
+
method: 'POST',
|
| 22 |
+
headers: { 'Content-Type': 'application/json' },
|
| 23 |
+
body: JSON.stringify(body),
|
| 24 |
+
})
|
| 25 |
+
if (!res.ok) {
|
| 26 |
+
const err = await res.json().catch(() => ({})) as { detail?: string }
|
| 27 |
+
throw new ApiError(err.detail ?? `HTTP ${res.status}`, true)
|
| 28 |
+
}
|
| 29 |
+
return res.json() as Promise<T>
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function postForm<T>(path: string, formData: FormData): Promise<T> {
|
| 33 |
+
const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
|
| 34 |
+
if (!res.ok) {
|
| 35 |
+
const err = await res.json().catch(() => ({})) as { detail?: string }
|
| 36 |
+
throw new ApiError(err.detail ?? `HTTP ${res.status}`, true)
|
| 37 |
+
}
|
| 38 |
+
return res.json() as Promise<T>
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function get<T>(path: string, params: Record<string, string | number | undefined> = {}): Promise<T> {
|
| 42 |
+
const defined = Object.fromEntries(
|
| 43 |
+
Object.entries(params).filter(([, v]) => v !== undefined),
|
| 44 |
+
) as Record<string, string>
|
| 45 |
+
const qs = new URLSearchParams(defined).toString()
|
| 46 |
+
const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
|
| 47 |
+
if (!res.ok) throw new ApiError(`HTTP ${res.status}`)
|
| 48 |
+
return res.json() as Promise<T>
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// ββ Public API surface βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
export const api = {
|
| 54 |
+
verifyText: (text: string): Promise<VerificationResponse> =>
|
| 55 |
+
post('/verify/text', { text }),
|
| 56 |
+
|
| 57 |
+
verifyUrl: (url: string): Promise<VerificationResponse> =>
|
| 58 |
+
post('/verify/url', { url }),
|
| 59 |
+
|
| 60 |
+
verifyImage: (file: File): Promise<VerificationResponse> => {
|
| 61 |
+
const f = new FormData()
|
| 62 |
+
f.append('file', file)
|
| 63 |
+
return postForm('/verify/image', f)
|
| 64 |
+
},
|
| 65 |
+
|
| 66 |
+
verifyVideo: (file: File): Promise<VerificationResponse> => {
|
| 67 |
+
const f = new FormData()
|
| 68 |
+
f.append('file', file)
|
| 69 |
+
return postForm('/verify/video', f)
|
| 70 |
+
},
|
| 71 |
+
|
| 72 |
+
history: (params?: HistoryParams): Promise<HistoryResponse> =>
|
| 73 |
+
get('/history', params as Record<string, string | number | undefined>),
|
| 74 |
+
|
| 75 |
+
trends: (): Promise<TrendsResponse> =>
|
| 76 |
+
get('/trends'),
|
| 77 |
+
|
| 78 |
+
health: (): Promise<HealthResponse> =>
|
| 79 |
+
get('/health'),
|
| 80 |
+
} as const
|
| 81 |
+
|
| 82 |
+
// Re-export error class for consumers
|
| 83 |
+
export { ApiError } from './types'
|
| 84 |
+
export type { ApiErrorType }
|
frontend/src/components/Navbar.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import { NavLink } from 'react-router-dom'
|
| 2 |
import { Radar, Clock, TrendingUp, ShieldCheck } from 'lucide-react'
|
|
|
|
| 3 |
|
| 4 |
const NAV_LINKS = [
|
| 5 |
{ to: '/', icon: ShieldCheck, label: 'Verify' },
|
|
@@ -9,55 +10,69 @@ const NAV_LINKS = [
|
|
| 9 |
|
| 10 |
export default function Navbar() {
|
| 11 |
return (
|
| 12 |
-
/* semantic <header> β web-design-guidelines: semantic HTML */
|
| 13 |
<header
|
| 14 |
role="banner"
|
| 15 |
style={{ background: 'var(--bg-surface)', borderBottom: '1px solid var(--border)' }}
|
| 16 |
-
className="sticky top-0 z-50
|
| 17 |
>
|
| 18 |
-
{/*
|
| 19 |
-
<div
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
</div>
|
| 62 |
</header>
|
| 63 |
)
|
|
|
|
| 1 |
+
import { NavLink, Link } from 'react-router-dom'
|
| 2 |
import { Radar, Clock, TrendingUp, ShieldCheck } from 'lucide-react'
|
| 3 |
+
import { PAGE_STYLE } from '../App.jsx'
|
| 4 |
|
| 5 |
const NAV_LINKS = [
|
| 6 |
{ to: '/', icon: ShieldCheck, label: 'Verify' },
|
|
|
|
| 10 |
|
| 11 |
export default function Navbar() {
|
| 12 |
return (
|
|
|
|
| 13 |
<header
|
| 14 |
role="banner"
|
| 15 |
style={{ background: 'var(--bg-surface)', borderBottom: '1px solid var(--border)' }}
|
| 16 |
+
className="sticky top-0 z-50 h-14"
|
| 17 |
>
|
| 18 |
+
{/* Inner content aligned to same width as page content */}
|
| 19 |
+
<div style={{
|
| 20 |
+
...PAGE_STYLE,
|
| 21 |
+
display: 'flex',
|
| 22 |
+
alignItems: 'center',
|
| 23 |
+
justifyContent: 'space-between',
|
| 24 |
+
height: '100%',
|
| 25 |
+
}}>
|
| 26 |
+
{/* Logo β Link to home */}
|
| 27 |
+
<Link
|
| 28 |
+
to="/"
|
| 29 |
+
className="flex items-center gap-2"
|
| 30 |
+
aria-label="PhilVerify home"
|
| 31 |
+
style={{ textDecoration: 'none' }}
|
| 32 |
+
>
|
| 33 |
+
<Radar size={18} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
|
| 34 |
+
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>
|
| 35 |
+
PHIL<span style={{ color: 'var(--accent-red)' }}>VERIFY</span>
|
| 36 |
+
</span>
|
| 37 |
+
</Link>
|
| 38 |
|
| 39 |
+
{/* Nav */}
|
| 40 |
+
<nav aria-label="Main navigation">
|
| 41 |
+
<ul className="flex items-center gap-2" role="list">
|
| 42 |
+
{NAV_LINKS.map(({ to, icon: Icon, label }) => (
|
| 43 |
+
<li key={to}>
|
| 44 |
+
<NavLink to={to} end={to === '/'} className="nav-link-item">
|
| 45 |
+
{({ isActive }) => (
|
| 46 |
+
<div
|
| 47 |
+
className="flex items-center gap-2 px-4 py-2 text-xs font-semibold transition-colors"
|
| 48 |
+
style={{
|
| 49 |
+
fontFamily: 'var(--font-display)',
|
| 50 |
+
letterSpacing: '0.08em',
|
| 51 |
+
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
| 52 |
+
borderBottom: isActive ? '2px solid var(--accent-red)' : '2px solid transparent',
|
| 53 |
+
minHeight: 44,
|
| 54 |
+
display: 'flex',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
}}
|
| 57 |
+
>
|
| 58 |
+
<Icon size={13} aria-hidden="true" />
|
| 59 |
+
{label}
|
| 60 |
+
</div>
|
| 61 |
+
)}
|
| 62 |
+
</NavLink>
|
| 63 |
+
</li>
|
| 64 |
+
))}
|
| 65 |
+
</ul>
|
| 66 |
+
</nav>
|
| 67 |
|
| 68 |
+
{/* Live indicator */}
|
| 69 |
+
<div className="flex items-center gap-1.5 text-xs tabular"
|
| 70 |
+
style={{ color: 'var(--text-muted)' }}
|
| 71 |
+
aria-label="API status: live">
|
| 72 |
+
<span className="w-1.5 h-1.5 rounded-full" aria-hidden="true"
|
| 73 |
+
style={{ background: 'var(--accent-green)' }} />
|
| 74 |
+
LIVE
|
| 75 |
+
</div>
|
| 76 |
</div>
|
| 77 |
</header>
|
| 78 |
)
|
frontend/src/components/SkeletonCard.jsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SkeletonCard β Phase 8: Loading state skeleton screens
|
| 3 |
+
* Used while the verification API call is in-flight.
|
| 4 |
+
* web-design-guidelines: content-jumping β reserve space for async content.
|
| 5 |
+
* web-design-guidelines: prefers-reduced-motion β skip animation if user prefers.
|
| 6 |
+
*/
|
| 7 |
+
export default function SkeletonCard({ lines = 3, height = null, className = '' }) {
|
| 8 |
+
return (
|
| 9 |
+
<div className={`card p-5 ${className}`} aria-hidden="true">
|
| 10 |
+
{height ? (
|
| 11 |
+
<SkeletonBar style={{ height, borderRadius: 4 }} />
|
| 12 |
+
) : (
|
| 13 |
+
<div className="space-y-3">
|
| 14 |
+
{Array.from({ length: lines }).map((_, i) => (
|
| 15 |
+
<SkeletonBar key={i}
|
| 16 |
+
style={{
|
| 17 |
+
height: i === 0 ? 12 : 10,
|
| 18 |
+
width: i === lines - 1 ? '60%' : '100%',
|
| 19 |
+
}}
|
| 20 |
+
/>
|
| 21 |
+
))}
|
| 22 |
+
</div>
|
| 23 |
+
)}
|
| 24 |
+
</div>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function SkeletonBar({ style = {} }) {
|
| 29 |
+
return (
|
| 30 |
+
<div
|
| 31 |
+
style={{
|
| 32 |
+
background: 'var(--bg-elevated)',
|
| 33 |
+
borderRadius: 3,
|
| 34 |
+
overflow: 'hidden',
|
| 35 |
+
...style,
|
| 36 |
+
}}
|
| 37 |
+
>
|
| 38 |
+
<div style={{
|
| 39 |
+
width: '100%',
|
| 40 |
+
height: '100%',
|
| 41 |
+
background: 'linear-gradient(90deg, transparent 0%, rgba(245,240,232,0.05) 50%, transparent 100%)',
|
| 42 |
+
animation: 'shimmer 1.5s infinite',
|
| 43 |
+
}} />
|
| 44 |
+
</div>
|
| 45 |
+
)
|
| 46 |
+
}
|
frontend/src/components/WordHighlighter.jsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WordHighlighter β Phase 8: Suspicious Word Highlighter
|
| 3 |
+
* Highlights suspicious / clickbait trigger words in the claim text.
|
| 4 |
+
* Uses triggered_features from Layer 1 as hint words.
|
| 5 |
+
*
|
| 6 |
+
* architect-review: pure presentational, no side-effects.
|
| 7 |
+
* web-design-guidelines: uses <mark> with visible styles, screen-reader friendly.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
// Common suspicious/misinformation signal words to highlight
|
| 11 |
+
const SUSPICIOUS_PATTERNS = [
|
| 12 |
+
// English signals
|
| 13 |
+
/\b(shocking|exposed|revealed|secret|hoax|fake|false|confirmed|breaking|urgent|emergency|exclusive|banned|cover[\s-]?up|conspiracy|miracle|crisis|scandal|leaked|hidden|truth|they don't want you to know)\b/gi,
|
| 14 |
+
// Filipino signals
|
| 15 |
+
/\b(grabe|nakakagulat|totoo|peke|huwag maniwala|nagsisinungaling|lihim|inilabas|natuklasan|katotohanan|panlilinlang|kahirap-hirap|itinatago)\b/gi,
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
function getHighlightedSegments(text, triggerWords = []) {
|
| 19 |
+
if (!text) return []
|
| 20 |
+
|
| 21 |
+
// Build a combined pattern from both static patterns + dynamic trigger words
|
| 22 |
+
const allPatterns = [...SUSPICIOUS_PATTERNS]
|
| 23 |
+
|
| 24 |
+
if (triggerWords.length > 0) {
|
| 25 |
+
const escaped = triggerWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
| 26 |
+
allPatterns.push(new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'))
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Find all match intervals
|
| 30 |
+
const matches = []
|
| 31 |
+
for (const pattern of allPatterns) {
|
| 32 |
+
pattern.lastIndex = 0
|
| 33 |
+
let m
|
| 34 |
+
while ((m = pattern.exec(text)) !== null) {
|
| 35 |
+
matches.push({ start: m.index, end: m.index + m[0].length, word: m[0] })
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (matches.length === 0) return [{ text, highlighted: false }]
|
| 40 |
+
|
| 41 |
+
// Sort + merge overlapping intervals
|
| 42 |
+
matches.sort((a, b) => a.start - b.start)
|
| 43 |
+
const merged = []
|
| 44 |
+
for (const m of matches) {
|
| 45 |
+
const last = merged[merged.length - 1]
|
| 46 |
+
if (last && m.start <= last.end) {
|
| 47 |
+
last.end = Math.max(last.end, m.end)
|
| 48 |
+
} else {
|
| 49 |
+
merged.push({ ...m })
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Build segments
|
| 54 |
+
const segments = []
|
| 55 |
+
let cursor = 0
|
| 56 |
+
for (const { start, end, word } of merged) {
|
| 57 |
+
if (cursor < start) segments.push({ text: text.slice(cursor, start), highlighted: false })
|
| 58 |
+
segments.push({ text: text.slice(start, end), highlighted: true, word })
|
| 59 |
+
cursor = end
|
| 60 |
+
}
|
| 61 |
+
if (cursor < text.length) segments.push({ text: text.slice(cursor), highlighted: false })
|
| 62 |
+
|
| 63 |
+
return segments
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export default function WordHighlighter({ text = '', triggerWords = [], className = '' }) {
|
| 67 |
+
const segments = getHighlightedSegments(text, triggerWords)
|
| 68 |
+
const hitCount = segments.filter(s => s.highlighted).length
|
| 69 |
+
|
| 70 |
+
if (segments.length === 1 && !segments[0].highlighted) {
|
| 71 |
+
// No suspicious words found
|
| 72 |
+
return (
|
| 73 |
+
<p className={className}
|
| 74 |
+
style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
|
| 75 |
+
{text}
|
| 76 |
+
</p>
|
| 77 |
+
)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div>
|
| 82 |
+
{hitCount > 0 && (
|
| 83 |
+
<p className="text-xs mb-2"
|
| 84 |
+
style={{
|
| 85 |
+
color: 'var(--accent-gold)',
|
| 86 |
+
fontFamily: 'var(--font-display)',
|
| 87 |
+
letterSpacing: '0.08em',
|
| 88 |
+
}}
|
| 89 |
+
aria-live="polite">
|
| 90 |
+
β {hitCount} suspicious signal{hitCount !== 1 ? 's' : ''} detected
|
| 91 |
+
</p>
|
| 92 |
+
)}
|
| 93 |
+
<p className={className} style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
|
| 94 |
+
{segments.map((seg, i) =>
|
| 95 |
+
seg.highlighted ? (
|
| 96 |
+
<mark key={i}
|
| 97 |
+
title={`Suspicious signal: "${seg.word}"`}
|
| 98 |
+
style={{
|
| 99 |
+
background: 'rgba(220, 38, 38, 0.18)',
|
| 100 |
+
color: '#f87171',
|
| 101 |
+
borderRadius: 2,
|
| 102 |
+
padding: '0 2px',
|
| 103 |
+
fontWeight: 600,
|
| 104 |
+
outline: '1px solid rgba(220,38,38,0.3)',
|
| 105 |
+
}}>
|
| 106 |
+
{seg.text}
|
| 107 |
+
</mark>
|
| 108 |
+
) : (
|
| 109 |
+
<span key={i}>{seg.text}</span>
|
| 110 |
+
)
|
| 111 |
+
)}
|
| 112 |
+
</p>
|
| 113 |
+
</div>
|
| 114 |
+
)
|
| 115 |
+
}
|
frontend/src/firebase.js
CHANGED
|
@@ -13,14 +13,28 @@ const firebaseConfig = {
|
|
| 13 |
const app = initializeApp(firebaseConfig)
|
| 14 |
export const db = getFirestore(app)
|
| 15 |
|
| 16 |
-
/**
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const q = query(
|
| 19 |
collection(db, 'verifications'),
|
| 20 |
orderBy('timestamp', 'desc'),
|
| 21 |
limit(20)
|
| 22 |
)
|
| 23 |
-
return onSnapshot(
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 13 |
const app = initializeApp(firebaseConfig)
|
| 14 |
export const db = getFirestore(app)
|
| 15 |
|
| 16 |
+
/**
|
| 17 |
+
* Subscribe to the 20 most recent verifications in real-time.
|
| 18 |
+
* @param {Function} callback - called with array of docs on each update
|
| 19 |
+
* @param {Function} [onError] - called with Error when Firestore is unreachable (e.g. ad blocker)
|
| 20 |
+
* @returns unsubscribe function
|
| 21 |
+
*/
|
| 22 |
+
export function subscribeToHistory(callback, onError) {
|
| 23 |
const q = query(
|
| 24 |
collection(db, 'verifications'),
|
| 25 |
orderBy('timestamp', 'desc'),
|
| 26 |
limit(20)
|
| 27 |
)
|
| 28 |
+
return onSnapshot(
|
| 29 |
+
q,
|
| 30 |
+
(snap) => {
|
| 31 |
+
callback(snap.docs.map(d => ({ id: d.id, ...d.data() })))
|
| 32 |
+
},
|
| 33 |
+
(error) => {
|
| 34 |
+
// Firestore blocked (ERR_BLOCKED_BY_CLIENT from ad blockers) or
|
| 35 |
+
// permission denied β fail fast and let caller fall back to REST.
|
| 36 |
+
console.warn('[PhilVerify] Firestore unavailable:', error.code || error.message)
|
| 37 |
+
if (onError) onError(error)
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
}
|
frontend/src/index.css
CHANGED
|
@@ -97,6 +97,13 @@ h4 {
|
|
| 97 |
border-radius: 4px;
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
/* ββ Touch (web-design-guidelines) βββββββββββββββββββββ */
|
| 101 |
button,
|
| 102 |
a,
|
|
@@ -161,7 +168,17 @@ a,
|
|
| 161 |
/* ββ Left-rule accent divider ββββββββββββββββββββββββββ */
|
| 162 |
.ruled {
|
| 163 |
border-left: 3px solid var(--accent-red);
|
| 164 |
-
padding-left:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
/* ββ Animations (frontend-design: one orchestrated reveal) βββ */
|
|
@@ -222,6 +239,16 @@ a,
|
|
| 222 |
.bar-fill {
|
| 223 |
animation: barGrow 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 224 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
/* Fallback: no animation for reduced-motion users */
|
|
|
|
| 97 |
border-radius: 4px;
|
| 98 |
}
|
| 99 |
|
| 100 |
+
/* Textarea: use box-shadow ring for :focus-visible so keyboard users see focus;
|
| 101 |
+
border-color animation still handled by onFocus/onBlur JS handlers */
|
| 102 |
+
.claim-textarea:focus-visible {
|
| 103 |
+
outline: none;
|
| 104 |
+
box-shadow: 0 0 0 2px var(--accent-red);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
/* ββ Touch (web-design-guidelines) βββββββββββββββββββββ */
|
| 108 |
button,
|
| 109 |
a,
|
|
|
|
| 168 |
/* ββ Left-rule accent divider ββββββββββββββββββββββββββ */
|
| 169 |
.ruled {
|
| 170 |
border-left: 3px solid var(--accent-red);
|
| 171 |
+
padding-left: 16px;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/* ββ Nav link hover ββββββββββββββββββββββββββββββββββββββββ */
|
| 175 |
+
a.nav-link-item {
|
| 176 |
+
text-decoration: none;
|
| 177 |
+
display: block;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
a.nav-link-item:hover > div {
|
| 181 |
+
color: var(--text-primary) !important;
|
| 182 |
}
|
| 183 |
|
| 184 |
/* ββ Animations (frontend-design: one orchestrated reveal) βββ */
|
|
|
|
| 239 |
.bar-fill {
|
| 240 |
animation: barGrow 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 241 |
}
|
| 242 |
+
|
| 243 |
+
@keyframes shimmer {
|
| 244 |
+
0% {
|
| 245 |
+
transform: translateX(-100%);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
100% {
|
| 249 |
+
transform: translateX(100%);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
}
|
| 253 |
|
| 254 |
/* Fallback: no animation for reduced-motion users */
|
frontend/src/pages/HistoryPage.jsx
CHANGED
|
@@ -1,53 +1,480 @@
|
|
| 1 |
-
import { useEffect, useState } from 'react'
|
| 2 |
import { subscribeToHistory } from '../firebase.js'
|
| 3 |
-
import { timeAgo } from '../utils/format.js'
|
|
|
|
|
|
|
| 4 |
import VerdictBadge from '../components/VerdictBadge.jsx'
|
| 5 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export default function HistoryPage() {
|
| 8 |
const [entries, setEntries] = useState([])
|
| 9 |
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
useEffect(() => {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
})
|
| 17 |
-
return
|
| 18 |
-
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
return (
|
| 21 |
-
<main
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
<div>
|
| 24 |
-
<h1 style={{ fontSize:
|
| 25 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 26 |
-
Real-time from Firestore
|
| 27 |
-
{
|
| 28 |
-
{' β '}<span className="tabular">{entries.length}</span> records
|
| 29 |
</p>
|
| 30 |
</div>
|
| 31 |
-
{/* aria-label on icon wrapper */}
|
| 32 |
<div className="flex items-center gap-1.5 text-xs"
|
| 33 |
-
style={{
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</div>
|
| 38 |
</header>
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
{loading && (
|
| 41 |
-
<
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
</p>
|
| 45 |
)}
|
| 46 |
|
|
|
|
| 47 |
{!loading && entries.length === 0 && (
|
| 48 |
-
<div className="card p-
|
| 49 |
-
<Clock size={28} aria-hidden="true"
|
| 50 |
-
style={{ color: 'var(--text-muted)', margin: '0 auto 12px' }} />
|
| 51 |
<p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
|
| 52 |
No verifications yet
|
| 53 |
</p>
|
|
@@ -57,47 +484,86 @@ export default function HistoryPage() {
|
|
| 57 |
</div>
|
| 58 |
)}
|
| 59 |
|
| 60 |
-
{/*
|
| 61 |
-
{
|
| 62 |
-
<
|
| 63 |
-
{
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
<p className="text-sm truncate" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
|
| 70 |
-
{e.text_preview || 'No
|
| 71 |
</p>
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
{e.input_type?.toUpperCase() ?? 'TEXT'}
|
| 83 |
-
</span>
|
| 84 |
-
{/* web-design-guidelines: Intl.DateTimeFormat via timeAgo util */}
|
| 85 |
-
<time className="text-xs tabular" style={{ color: 'var(--text-muted)' }}
|
| 86 |
-
dateTime={e.timestamp}>
|
| 87 |
-
{timeAgo(e.timestamp)}
|
| 88 |
-
</time>
|
| 89 |
-
</div>
|
| 90 |
-
</div>
|
| 91 |
-
<div className="flex items-center gap-3 shrink-0">
|
| 92 |
-
<span className="tabular text-sm font-bold" style={{ color: 'var(--text-muted)' }}>
|
| 93 |
-
{Math.round(e.final_score)}
|
| 94 |
</span>
|
| 95 |
-
<VerdictBadge verdict={e.verdict} size="sm" />
|
| 96 |
</div>
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
)}
|
| 102 |
</main>
|
| 103 |
)
|
|
|
|
| 1 |
+
import { useEffect, useState, useCallback, useMemo } from 'react'
|
| 2 |
import { subscribeToHistory } from '../firebase.js'
|
| 3 |
+
import { timeAgo, VERDICT_MAP, scoreColor } from '../utils/format.js'
|
| 4 |
+
import { PAGE_STYLE } from '../App.jsx'
|
| 5 |
+
import { api } from '../api'
|
| 6 |
import VerdictBadge from '../components/VerdictBadge.jsx'
|
| 7 |
+
import SkeletonCard from '../components/SkeletonCard.jsx'
|
| 8 |
+
import { Clock, RefreshCw, WifiOff, ChevronUp, ChevronDown, ChevronsUpDown, X, Loader2, FileText, Globe, ImageIcon, Video } from 'lucide-react'
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
/* ββ Sort icon helper βββββββββββββββββββββββββββββββββββ */
|
| 12 |
+
function SortIcon({ field, current, dir }) {
|
| 13 |
+
if (current !== field) return <ChevronsUpDown size={10} aria-hidden="true" style={{ opacity: 0.3 }} />
|
| 14 |
+
return dir === 'asc'
|
| 15 |
+
? <ChevronUp size={10} aria-hidden="true" />
|
| 16 |
+
: <ChevronDown size={10} aria-hidden="true" />
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* ββ Column header button βββββββββββββββββββββββββββββββ */
|
| 20 |
+
function ColHeader({ children, field, sort, dir, onSort }) {
|
| 21 |
+
const active = sort === field
|
| 22 |
+
return (
|
| 23 |
+
<button
|
| 24 |
+
onClick={() => onSort(field)}
|
| 25 |
+
className="flex items-center gap-1 text-xs font-semibold uppercase"
|
| 26 |
+
style={{
|
| 27 |
+
fontFamily: 'var(--font-display)',
|
| 28 |
+
letterSpacing: '0.1em',
|
| 29 |
+
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
| 30 |
+
background: 'none',
|
| 31 |
+
border: 'none',
|
| 32 |
+
cursor: 'pointer',
|
| 33 |
+
padding: 0,
|
| 34 |
+
minHeight: 44,
|
| 35 |
+
}}
|
| 36 |
+
aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
| 37 |
+
{children}
|
| 38 |
+
<SortIcon field={field} current={sort} dir={dir} />
|
| 39 |
+
</button>
|
| 40 |
+
)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* ββ Input-type icon βββββββββββββββββββββββββββββββββββ */
|
| 44 |
+
function InputTypeIcon({ type, size = 12 }) {
|
| 45 |
+
const icons = { url: Globe, image: ImageIcon, video: Video, text: FileText }
|
| 46 |
+
const Icon = icons[type] ?? FileText
|
| 47 |
+
return <Icon size={size} aria-hidden="true" />
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ββ Detail Modal ββββββββββββββββββββββββββββββββββββββ */
|
| 51 |
+
function DetailModal({ id, onClose }) {
|
| 52 |
+
const [data, setData] = useState(null)
|
| 53 |
+
const [loadingDetail, setLoadingDetail] = useState(true)
|
| 54 |
+
const [error, setError] = useState(null)
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
setLoadingDetail(true); setError(null)
|
| 58 |
+
api.historyDetail(id)
|
| 59 |
+
.then(setData)
|
| 60 |
+
.catch(e => setError(e.message ?? 'Failed to load'))
|
| 61 |
+
.finally(() => setLoadingDetail(false))
|
| 62 |
+
}, [id])
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
function onKey(e) { if (e.key === 'Escape') onClose() }
|
| 66 |
+
window.addEventListener('keydown', onKey)
|
| 67 |
+
return () => window.removeEventListener('keydown', onKey)
|
| 68 |
+
}, [onClose])
|
| 69 |
+
|
| 70 |
+
const s = scoreColor(data?.final_score)
|
| 71 |
+
const layer1 = data?.layer1
|
| 72 |
+
const layer2 = data?.layer2
|
| 73 |
+
const entities = data?.entities?.entities ?? []
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
role="dialog"
|
| 78 |
+
aria-modal="true"
|
| 79 |
+
aria-label="Verification detail"
|
| 80 |
+
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
| 81 |
+
style={{
|
| 82 |
+
position: 'fixed', inset: 0, zIndex: 1000,
|
| 83 |
+
background: 'rgba(0,0,0,0.6)',
|
| 84 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 85 |
+
padding: '16px',
|
| 86 |
+
backdropFilter: 'blur(4px)',
|
| 87 |
+
}}>
|
| 88 |
+
<div className="card"
|
| 89 |
+
style={{
|
| 90 |
+
width: '100%', maxWidth: 600,
|
| 91 |
+
maxHeight: '90vh', overflowY: 'auto',
|
| 92 |
+
padding: 0,
|
| 93 |
+
position: 'relative',
|
| 94 |
+
borderColor: 'var(--border-light)',
|
| 95 |
+
display: 'flex', flexDirection: 'column',
|
| 96 |
+
}}>
|
| 97 |
+
{/* Header */}
|
| 98 |
+
<div className="flex items-center justify-between"
|
| 99 |
+
style={{
|
| 100 |
+
padding: '16px 20px',
|
| 101 |
+
borderBottom: '1px solid var(--border)',
|
| 102 |
+
background: 'var(--bg-elevated)',
|
| 103 |
+
position: 'sticky', top: 0, zIndex: 1,
|
| 104 |
+
}}>
|
| 105 |
+
<div className="flex items-center gap-2">
|
| 106 |
+
<span className="text-xs font-semibold uppercase"
|
| 107 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 108 |
+
Verification Detail
|
| 109 |
+
</span>
|
| 110 |
+
{data && <span className="text-xs tabular"
|
| 111 |
+
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', fontSize: 10 }}>
|
| 112 |
+
{id.slice(0, 8)}β¦
|
| 113 |
+
</span>}
|
| 114 |
+
</div>
|
| 115 |
+
<button onClick={onClose} aria-label="Close"
|
| 116 |
+
style={{
|
| 117 |
+
background: 'none', border: 'none', cursor: 'pointer',
|
| 118 |
+
color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
|
| 119 |
+
padding: 4, borderRadius: 4,
|
| 120 |
+
}}>
|
| 121 |
+
<X size={16} />
|
| 122 |
+
</button>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* Body */}
|
| 126 |
+
<div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: 18 }}>
|
| 127 |
+
|
| 128 |
+
{loadingDetail && (
|
| 129 |
+
<div className="flex items-center justify-center" style={{ padding: 40, gap: 8, color: 'var(--text-muted)' }}>
|
| 130 |
+
<Loader2 size={18} className="animate-spin" />
|
| 131 |
+
<span className="text-sm" style={{ fontFamily: 'var(--font-body)' }}>Loadingβ¦</span>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{error && (
|
| 136 |
+
<div className="text-sm text-center" style={{ color: 'var(--fake)', padding: 32, fontFamily: 'var(--font-body)' }}>
|
| 137 |
+
{error}
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
|
| 141 |
+
{data && !loadingDetail && (<>
|
| 142 |
+
{/* Verdict + Score row */}
|
| 143 |
+
<div className="flex items-center gap-3 flex-wrap">
|
| 144 |
+
<VerdictBadge verdict={data.verdict} size="md" />
|
| 145 |
+
<span className="tabular font-bold"
|
| 146 |
+
style={{ fontSize: 28, fontFamily: 'var(--font-mono)', color: s, lineHeight: 1 }}>
|
| 147 |
+
{Math.round(data.final_score)}
|
| 148 |
+
</span>
|
| 149 |
+
<span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 150 |
+
score
|
| 151 |
+
</span>
|
| 152 |
+
<span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', marginLeft: 'auto' }}>
|
| 153 |
+
{Math.round(data.confidence)}% confidence
|
| 154 |
+
</span>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Meta row */}
|
| 158 |
+
<div className="flex items-center flex-wrap gap-2">
|
| 159 |
+
<span className="flex items-center gap-1 text-xs px-1.5 py-0.5"
|
| 160 |
+
style={{
|
| 161 |
+
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
|
| 162 |
+
borderRadius: 3, fontFamily: 'var(--font-display)', letterSpacing: '0.08em',
|
| 163 |
+
color: 'var(--text-muted)',
|
| 164 |
+
}}>
|
| 165 |
+
<InputTypeIcon type={data.input_type} />
|
| 166 |
+
{data.input_type?.toUpperCase()}
|
| 167 |
+
</span>
|
| 168 |
+
{data.language && (
|
| 169 |
+
<span className="text-xs px-1.5 py-0.5"
|
| 170 |
+
style={{
|
| 171 |
+
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
|
| 172 |
+
borderRadius: 3, fontFamily: 'var(--font-display)', letterSpacing: '0.08em',
|
| 173 |
+
color: 'var(--text-muted)', textTransform: 'uppercase',
|
| 174 |
+
}}>
|
| 175 |
+
{data.language}
|
| 176 |
+
</span>
|
| 177 |
+
)}
|
| 178 |
+
<time className="text-xs tabular ml-auto"
|
| 179 |
+
style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
|
| 180 |
+
dateTime={data.timestamp}>
|
| 181 |
+
{new Date(data.timestamp).toLocaleString()}
|
| 182 |
+
</time>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Claim / text */}
|
| 186 |
+
{(data.claim_used || data.text_preview) && (
|
| 187 |
+
<div style={{ borderLeft: '2px solid var(--border-light)', paddingLeft: 12 }}>
|
| 188 |
+
<p className="text-xs font-semibold uppercase mb-1"
|
| 189 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 190 |
+
Claim
|
| 191 |
+
</p>
|
| 192 |
+
<p className="text-sm" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)', lineHeight: 1.6 }}>
|
| 193 |
+
{data.claim_used || data.text_preview}
|
| 194 |
+
</p>
|
| 195 |
+
</div>
|
| 196 |
+
)}
|
| 197 |
+
|
| 198 |
+
{/* Layer 1 */}
|
| 199 |
+
{layer1 && (
|
| 200 |
+
<div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '12px 14px', border: '1px solid var(--border)' }}>
|
| 201 |
+
<p className="text-xs font-semibold uppercase mb-2"
|
| 202 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 203 |
+
Layer 1 β NLP Analysis
|
| 204 |
+
</p>
|
| 205 |
+
<div className="flex items-center gap-3 mb-2">
|
| 206 |
+
<VerdictBadge verdict={layer1.verdict} size="sm" />
|
| 207 |
+
<span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
|
| 208 |
+
{Math.round(layer1.confidence)}% confidence
|
| 209 |
+
</span>
|
| 210 |
+
</div>
|
| 211 |
+
{layer1.triggered_features?.length > 0 && (
|
| 212 |
+
<div className="flex flex-wrap gap-1 mt-2">
|
| 213 |
+
{layer1.triggered_features.map(f => (
|
| 214 |
+
<span key={f} className="text-xs px-2 py-0.5"
|
| 215 |
+
style={{
|
| 216 |
+
background: 'var(--bg-hover)', border: '1px solid var(--border)',
|
| 217 |
+
borderRadius: 3, fontFamily: 'var(--font-mono)',
|
| 218 |
+
color: 'var(--text-secondary)', fontSize: 10,
|
| 219 |
+
}}>
|
| 220 |
+
{f}
|
| 221 |
+
</span>
|
| 222 |
+
))}
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
|
| 228 |
+
{/* Layer 2 */}
|
| 229 |
+
{layer2 && (
|
| 230 |
+
<div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '12px 14px', border: '1px solid var(--border)' }}>
|
| 231 |
+
<p className="text-xs font-semibold uppercase mb-2"
|
| 232 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 233 |
+
Layer 2 β Evidence Check
|
| 234 |
+
</p>
|
| 235 |
+
<div className="flex items-center gap-3 mb-2">
|
| 236 |
+
<VerdictBadge verdict={layer2.verdict} size="sm" />
|
| 237 |
+
<span className="tabular text-sm" style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
|
| 238 |
+
Evidence: {Math.round((layer2.evidence_score ?? 0) * 100)}%
|
| 239 |
+
</span>
|
| 240 |
+
</div>
|
| 241 |
+
{layer2.claim_used && (
|
| 242 |
+
<p className="text-xs italic"
|
| 243 |
+
style={{ fontFamily: 'var(--font-body)', color: 'var(--text-muted)', marginTop: 6 }}>
|
| 244 |
+
“{layer2.claim_used}”
|
| 245 |
+
</p>
|
| 246 |
+
)}
|
| 247 |
+
</div>
|
| 248 |
+
)}
|
| 249 |
+
|
| 250 |
+
{/* Sentiment */}
|
| 251 |
+
{(data.sentiment || data.emotion) && (
|
| 252 |
+
<div className="flex gap-3 flex-wrap">
|
| 253 |
+
{data.sentiment && (
|
| 254 |
+
<div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '10px 14px', border: '1px solid var(--border)', flex: 1 }}>
|
| 255 |
+
<p className="text-xs font-semibold uppercase mb-1"
|
| 256 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 257 |
+
Sentiment
|
| 258 |
+
</p>
|
| 259 |
+
<p className="text-sm capitalize" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)' }}>
|
| 260 |
+
{data.sentiment}
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
{data.emotion && (
|
| 265 |
+
<div style={{ background: 'var(--bg-elevated)', borderRadius: 4, padding: '10px 14px', border: '1px solid var(--border)', flex: 1 }}>
|
| 266 |
+
<p className="text-xs font-semibold uppercase mb-1"
|
| 267 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 268 |
+
Emotion
|
| 269 |
+
</p>
|
| 270 |
+
<p className="text-sm capitalize" style={{ fontFamily: 'var(--font-body)', color: 'var(--text-primary)' }}>
|
| 271 |
+
{data.emotion}
|
| 272 |
+
</p>
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
)}
|
| 277 |
+
|
| 278 |
+
{/* Entities */}
|
| 279 |
+
{entities.length > 0 && (
|
| 280 |
+
<div>
|
| 281 |
+
<p className="text-xs font-semibold uppercase mb-2"
|
| 282 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 283 |
+
Entities Detected
|
| 284 |
+
</p>
|
| 285 |
+
<div className="flex flex-wrap gap-1.5">
|
| 286 |
+
{entities.map((ent, i) => (
|
| 287 |
+
<span key={i} className="flex items-center gap-1 text-xs px-2 py-0.5"
|
| 288 |
+
style={{
|
| 289 |
+
background: 'var(--bg-elevated)', border: '1px solid var(--border-light)',
|
| 290 |
+
borderRadius: 3, fontFamily: 'var(--font-body)',
|
| 291 |
+
color: 'var(--text-primary)',
|
| 292 |
+
}}>
|
| 293 |
+
{ent.text ?? ent.entity ?? ent}
|
| 294 |
+
{(ent.label ?? ent.entity_type) && (
|
| 295 |
+
<span style={{ color: 'var(--text-muted)', fontSize: 9, fontFamily: 'var(--font-mono)', fontWeight: 700 }}>
|
| 296 |
+
{(ent.label ?? ent.entity_type).toUpperCase()}
|
| 297 |
+
</span>
|
| 298 |
+
)}
|
| 299 |
+
</span>
|
| 300 |
+
))}
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
)}
|
| 304 |
+
</>)}
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
)
|
| 309 |
+
}
|
| 310 |
|
| 311 |
export default function HistoryPage() {
|
| 312 |
const [entries, setEntries] = useState([])
|
| 313 |
const [loading, setLoading] = useState(true)
|
| 314 |
+
const [source, setSource] = useState('firestore')
|
| 315 |
+
const [sort, setSort] = useState('timestamp')
|
| 316 |
+
const [dir, setDir] = useState('desc')
|
| 317 |
+
const [filter, setFilter] = useState('all') // 'all' | 'Credible' | 'Unverified' | 'Likely Fake'
|
| 318 |
+
const [selectedId, setSelectedId] = useState(null)
|
| 319 |
+
|
| 320 |
+
const fetchRest = useCallback(() => {
|
| 321 |
+
api.history({ limit: 50 })
|
| 322 |
+
.then(data => {
|
| 323 |
+
const list = Array.isArray(data) ? data : (data.entries ?? [])
|
| 324 |
+
setEntries(list)
|
| 325 |
+
})
|
| 326 |
+
.catch(() => setEntries([]))
|
| 327 |
+
.finally(() => setLoading(false))
|
| 328 |
+
}, [])
|
| 329 |
|
| 330 |
useEffect(() => {
|
| 331 |
+
let resolved = false
|
| 332 |
+
let restInterval = null
|
| 333 |
+
let unsubRef = null // declared before subscribeToHistory so goRest() can reach it
|
| 334 |
+
|
| 335 |
+
function goRest() {
|
| 336 |
+
if (resolved) return
|
| 337 |
+
resolved = true
|
| 338 |
+
// Immediately kill the Firestore listener so the SDK stops retrying
|
| 339 |
+
// (prevents the ERR_BLOCKED_BY_CLIENT console flood from auto-retries)
|
| 340 |
+
unsubRef?.()
|
| 341 |
+
setSource('rest')
|
| 342 |
+
fetchRest()
|
| 343 |
+
restInterval = setInterval(fetchRest, 30_000)
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// Fallback to REST after 1.5 s if Firestore hasn't connected
|
| 347 |
+
const fallbackTimer = setTimeout(goRest, 1500)
|
| 348 |
+
|
| 349 |
+
unsubRef = subscribeToHistory(
|
| 350 |
+
(docs) => {
|
| 351 |
+
if (!resolved) { resolved = true; clearTimeout(fallbackTimer) }
|
| 352 |
+
setEntries(docs)
|
| 353 |
+
setLoading(false)
|
| 354 |
+
},
|
| 355 |
+
// onError: Firestore blocked by ad-blocker β instant REST fallback
|
| 356 |
+
() => {
|
| 357 |
+
clearTimeout(fallbackTimer)
|
| 358 |
+
goRest()
|
| 359 |
+
}
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
return () => { unsubRef?.(); clearTimeout(fallbackTimer); if (restInterval) clearInterval(restInterval) }
|
| 363 |
+
}, [fetchRest])
|
| 364 |
+
|
| 365 |
+
function handleSort(field) {
|
| 366 |
+
if (sort === field) setDir(d => d === 'asc' ? 'desc' : 'asc')
|
| 367 |
+
else { setSort(field); setDir('desc') }
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const filtered = useMemo(() => {
|
| 371 |
+
let data = [...entries]
|
| 372 |
+
if (filter !== 'all') data = data.filter(e => e.verdict === filter)
|
| 373 |
+
data.sort((a, b) => {
|
| 374 |
+
let av = a[sort], bv = b[sort]
|
| 375 |
+
if (sort === 'timestamp') { av = new Date(av); bv = new Date(bv) }
|
| 376 |
+
if (sort === 'final_score') { av = Number(av); bv = Number(bv) }
|
| 377 |
+
if (av < bv) return dir === 'asc' ? -1 : 1
|
| 378 |
+
if (av > bv) return dir === 'asc' ? 1 : -1
|
| 379 |
+
return 0
|
| 380 |
})
|
| 381 |
+
return data
|
| 382 |
+
}, [entries, sort, dir, filter])
|
| 383 |
+
|
| 384 |
+
const verdictCounts = useMemo(() => {
|
| 385 |
+
const counts = { all: entries.length, Credible: 0, Unverified: 0, 'Likely Fake': 0 }
|
| 386 |
+
entries.forEach(e => { if (counts[e.verdict] !== undefined) counts[e.verdict]++ })
|
| 387 |
+
return counts
|
| 388 |
+
}, [entries])
|
| 389 |
+
|
| 390 |
+
const FILTER_TABS = [
|
| 391 |
+
{ key: 'all', label: 'All', color: 'var(--text-secondary)' },
|
| 392 |
+
{ key: 'Credible', label: 'Verified', color: 'var(--credible)' },
|
| 393 |
+
{ key: 'Unverified', label: 'Unverified', color: 'var(--unverified)' },
|
| 394 |
+
{ key: 'Likely Fake', label: 'False', color: 'var(--fake)' },
|
| 395 |
+
]
|
| 396 |
|
| 397 |
return (
|
| 398 |
+
<main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
| 399 |
+
|
| 400 |
+
{/* ββ Header ββββββββββββββββββββββββββββββββββ */}
|
| 401 |
+
<header className="ruled fade-up-1 flex items-end justify-between flex-wrap gap-2">
|
| 402 |
<div>
|
| 403 |
+
<h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>History</h1>
|
| 404 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 405 |
+
{source === 'firestore' ? 'Real-time from Firestore' : 'Polling via REST API'}
|
| 406 |
+
{' β '}<span className="tabular" style={{ fontFamily: 'var(--font-mono)' }}>{entries.length}</span> records
|
|
|
|
| 407 |
</p>
|
| 408 |
</div>
|
|
|
|
| 409 |
<div className="flex items-center gap-1.5 text-xs"
|
| 410 |
+
style={{
|
| 411 |
+
color: source === 'rest' ? 'var(--accent-gold)' : 'var(--accent-green)',
|
| 412 |
+
fontFamily: 'var(--font-display)',
|
| 413 |
+
letterSpacing: '0.1em',
|
| 414 |
+
}}
|
| 415 |
+
aria-label={source === 'firestore' ? 'Live data' : 'Polling REST API'}>
|
| 416 |
+
<span className="w-1.5 h-1.5 rounded-full" style={{ background: 'currentColor' }} />
|
| 417 |
+
{source === 'rest' ? <><WifiOff size={11} aria-hidden="true" /> POLLING</> : <><RefreshCw size={11} aria-hidden="true" /> LIVE</>}
|
| 418 |
</div>
|
| 419 |
</header>
|
| 420 |
|
| 421 |
+
{/* ββ Firestore blocked notice βββββββββββββββββ */}
|
| 422 |
+
{source === 'rest' && !loading && (
|
| 423 |
+
<div className="card p-3 flex items-center gap-2"
|
| 424 |
+
style={{ borderColor: 'rgba(217,119,6,0.3)', background: 'rgba(217,119,6,0.05)' }}>
|
| 425 |
+
<WifiOff size={13} style={{ color: 'var(--accent-gold)', flexShrink: 0 }} aria-hidden="true" />
|
| 426 |
+
<p className="text-xs" style={{ color: 'var(--accent-gold)', fontFamily: 'var(--font-body)' }}>
|
| 427 |
+
Firestore may be blocked by an ad blocker β using REST fallback. Whitelist <code>firestore.googleapis.com</code> to restore live updates.
|
| 428 |
+
</p>
|
| 429 |
+
</div>
|
| 430 |
+
)}
|
| 431 |
+
|
| 432 |
+
{/* ββ Filter tabs ββββββββββββββββββββββββββββββ */}
|
| 433 |
+
{!loading && entries.length > 0 && (
|
| 434 |
+
<div role="tablist" aria-label="Filter by verdict" className="flex gap-1 flex-wrap fade-up-2">
|
| 435 |
+
{FILTER_TABS.map(({ key, label, color }) => (
|
| 436 |
+
<button key={key}
|
| 437 |
+
role="tab"
|
| 438 |
+
aria-selected={filter === key}
|
| 439 |
+
onClick={() => setFilter(key)}
|
| 440 |
+
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold transition-colors"
|
| 441 |
+
style={{
|
| 442 |
+
fontFamily: 'var(--font-display)',
|
| 443 |
+
letterSpacing: '0.07em',
|
| 444 |
+
background: filter === key ? 'var(--bg-elevated)' : 'transparent',
|
| 445 |
+
color: filter === key ? color : 'var(--text-muted)',
|
| 446 |
+
border: `1px solid ${filter === key ? 'var(--border-light)' : 'var(--border)'}`,
|
| 447 |
+
cursor: 'pointer',
|
| 448 |
+
borderRadius: 2,
|
| 449 |
+
minHeight: 44,
|
| 450 |
+
}}>
|
| 451 |
+
{label}
|
| 452 |
+
<span style={{
|
| 453 |
+
background: 'var(--bg-hover)',
|
| 454 |
+
padding: '0 5px',
|
| 455 |
+
borderRadius: 2,
|
| 456 |
+
fontSize: 10,
|
| 457 |
+
fontFamily: 'var(--font-mono)',
|
| 458 |
+
color: filter === key ? color : 'var(--text-muted)',
|
| 459 |
+
}}>
|
| 460 |
+
{verdictCounts[key]}
|
| 461 |
+
</span>
|
| 462 |
+
</button>
|
| 463 |
+
))}
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
|
| 467 |
+
{/* ββ Loading skeleton ββββββββββββββββββββββββ */}
|
| 468 |
{loading && (
|
| 469 |
+
<div className="space-y-2" aria-live="polite" aria-label="Loading history">
|
| 470 |
+
{[...Array(5)].map((_, i) => <SkeletonCard key={i} lines={2} />)}
|
| 471 |
+
</div>
|
|
|
|
| 472 |
)}
|
| 473 |
|
| 474 |
+
{/* ββ Empty state ββββββββββββββββββββββββββββββ */}
|
| 475 |
{!loading && entries.length === 0 && (
|
| 476 |
+
<div className="card p-16 text-center fade-up">
|
| 477 |
+
<Clock size={28} aria-hidden="true" style={{ color: 'var(--text-muted)', margin: '0 auto 12px' }} />
|
|
|
|
| 478 |
<p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
|
| 479 |
No verifications yet
|
| 480 |
</p>
|
|
|
|
| 484 |
</div>
|
| 485 |
)}
|
| 486 |
|
| 487 |
+
{/* ββ Table βββββββββββββββββββββββββββββββββββ */}
|
| 488 |
+
{filtered.length > 0 && (
|
| 489 |
+
<div className="card overflow-hidden fade-up-3">
|
| 490 |
+
{/* Table header */}
|
| 491 |
+
<div className="px-4 py-2 grid items-center"
|
| 492 |
+
style={{
|
| 493 |
+
gridTemplateColumns: '1fr 56px 90px 110px',
|
| 494 |
+
gap: '0 12px',
|
| 495 |
+
borderBottom: '1px solid var(--border)',
|
| 496 |
+
background: 'var(--bg-elevated)',
|
| 497 |
+
}}
|
| 498 |
+
role="row">
|
| 499 |
+
<ColHeader field="text_preview" sort={sort} dir={dir} onSort={handleSort}>Claim</ColHeader>
|
| 500 |
+
<div style={{ textAlign: 'right' }}>
|
| 501 |
+
<ColHeader field="final_score" sort={sort} dir={dir} onSort={handleSort}>Score</ColHeader>
|
| 502 |
+
</div>
|
| 503 |
+
<ColHeader field="timestamp" sort={sort} dir={dir} onSort={handleSort}>Time</ColHeader>
|
| 504 |
+
<span className="text-xs font-semibold uppercase"
|
| 505 |
+
style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.1em', color: 'var(--text-muted)' }}>
|
| 506 |
+
Verdict
|
| 507 |
+
</span>
|
| 508 |
+
</div>
|
| 509 |
+
|
| 510 |
+
{/* Rows */}
|
| 511 |
+
<ul className="divide-y" style={{ '--tw-divide-color': 'var(--border)' }} role="list" aria-label="Verification history" aria-live="polite">
|
| 512 |
+
{filtered.map((e, i) => (
|
| 513 |
+
<li key={e.id}
|
| 514 |
+
className="px-4 py-3 grid items-center fade-up hover:bg-[var(--bg-elevated)] transition-colors"
|
| 515 |
+
role="button"
|
| 516 |
+
tabIndex={0}
|
| 517 |
+
onClick={() => setSelectedId(e.id)}
|
| 518 |
+
onKeyDown={ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setSelectedId(e.id) } }}
|
| 519 |
+
style={{
|
| 520 |
+
gridTemplateColumns: '1fr 56px 90px 110px',
|
| 521 |
+
gap: '0 12px',
|
| 522 |
+
animationDelay: `${Math.min(i * 25, 200)}ms`,
|
| 523 |
+
borderBottom: '1px solid var(--border)',
|
| 524 |
+
cursor: 'pointer',
|
| 525 |
+
}}>
|
| 526 |
+
<div className="min-w-0">
|
| 527 |
<p className="text-sm truncate" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
|
| 528 |
+
{e.text_preview || 'No preview'}
|
| 529 |
</p>
|
| 530 |
+
<span className="text-xs px-1.5 py-0.5 mt-1 inline-block"
|
| 531 |
+
style={{
|
| 532 |
+
background: 'var(--bg-elevated)',
|
| 533 |
+
color: 'var(--text-muted)',
|
| 534 |
+
fontFamily: 'var(--font-display)',
|
| 535 |
+
letterSpacing: '0.08em',
|
| 536 |
+
fontSize: 9,
|
| 537 |
+
borderRadius: 2,
|
| 538 |
+
}}>
|
| 539 |
+
{e.input_type?.toUpperCase() ?? 'TEXT'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
</span>
|
|
|
|
| 541 |
</div>
|
| 542 |
+
<span className="tabular font-bold text-sm"
|
| 543 |
+
style={{ color: scoreColor(e.final_score), fontFamily: 'var(--font-mono)', textAlign: 'right', display: 'block' }}>
|
| 544 |
+
{Math.round(e.final_score)}
|
| 545 |
+
</span>
|
| 546 |
+
<time className="text-xs tabular" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}
|
| 547 |
+
dateTime={e.timestamp}>
|
| 548 |
+
{timeAgo(e.timestamp)}
|
| 549 |
+
</time>
|
| 550 |
+
<VerdictBadge verdict={e.verdict} size="sm" />
|
| 551 |
+
</li>
|
| 552 |
+
))}
|
| 553 |
+
</ul>
|
| 554 |
+
</div>
|
| 555 |
+
)}
|
| 556 |
+
|
| 557 |
+
{/* ββ No results after filter βββββββββββββββββββ */}
|
| 558 |
+
{!loading && entries.length > 0 && filtered.length === 0 && (
|
| 559 |
+
<p className="text-center text-sm py-8" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 560 |
+
No {filter} verifications found.
|
| 561 |
+
</p>
|
| 562 |
+
)}
|
| 563 |
+
|
| 564 |
+
{/* ββ Detail Modal ββββββββββββββββββββββββββββ */}
|
| 565 |
+
{selectedId && (
|
| 566 |
+
<DetailModal id={selectedId} onClose={() => setSelectedId(null)} />
|
| 567 |
)}
|
| 568 |
</main>
|
| 569 |
)
|
frontend/src/pages/TrendsPage.jsx
CHANGED
|
@@ -1,45 +1,80 @@
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
-
import { api } from '../api
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
/*
|
| 8 |
const ChartTooltip = ({ active, payload }) => {
|
| 9 |
if (!active || !payload?.length) return null
|
|
|
|
| 10 |
return (
|
| 11 |
-
<div
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
<p className="text-xs font-bold" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
| 14 |
-
{
|
| 15 |
</p>
|
| 16 |
-
<p className="tabular text-xs mt-0.5" style={{ color: 'var(--
|
| 17 |
-
|
| 18 |
</p>
|
| 19 |
</div>
|
| 20 |
)
|
| 21 |
}
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
if (!data?.length) return null
|
| 25 |
return (
|
| 26 |
<section aria-label={title} className="card p-5 fade-up-2">
|
| 27 |
-
<
|
| 28 |
-
|
| 29 |
-
{
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
<ResponsiveContainer width="100%" height={200}>
|
| 33 |
-
<BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom:
|
|
|
|
| 34 |
<XAxis dataKey={dataKey}
|
| 35 |
-
tick={{ fontSize:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
axisLine={false} tickLine={false} />
|
| 37 |
<YAxis
|
| 38 |
-
tick={{ fontSize:
|
| 39 |
axisLine={false} tickLine={false} />
|
| 40 |
-
<Tooltip content={<ChartTooltip />}
|
| 41 |
-
|
| 42 |
-
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
|
| 43 |
{data.map((_, i) => (
|
| 44 |
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
| 45 |
))}
|
|
@@ -50,6 +85,54 @@ function ChartSection({ title, data, dataKey }) {
|
|
| 50 |
)
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
export default function TrendsPage() {
|
| 54 |
const [data, setData] = useState(null)
|
| 55 |
const [loading, setLoading] = useState(true)
|
|
@@ -62,66 +145,107 @@ export default function TrendsPage() {
|
|
| 62 |
.finally(() => setLoading(false))
|
| 63 |
}, [])
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if (error) return (
|
| 71 |
-
<p role="alert" className="text-center py-24 text-sm" style={{ color: '#f87171' }}>
|
| 72 |
-
Error: {error}
|
| 73 |
-
</p>
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
const entityData = Object.entries(data?.top_entities || {})
|
| 77 |
-
.sort(([, a], [, b]) => b - a).slice(0, 8)
|
| 78 |
-
.map(([name, count]) => ({ name, count }))
|
| 79 |
|
| 80 |
-
|
|
|
|
| 81 |
|
| 82 |
const verdicts = [
|
| 83 |
{ label: 'VERIFIED', count: data?.verdict_distribution?.Credible ?? 0, color: 'var(--credible)' },
|
| 84 |
{ label: 'UNVERIFIED', count: data?.verdict_distribution?.Unverified ?? 0, color: 'var(--unverified)' },
|
| 85 |
{ label: 'FALSE', count: data?.verdict_distribution?.['Likely Fake'] ?? 0, color: 'var(--fake)' },
|
| 86 |
]
|
|
|
|
| 87 |
const hasData = entityData.length > 0 || verdicts.some(v => v.count > 0)
|
| 88 |
|
| 89 |
return (
|
| 90 |
-
<main
|
|
|
|
|
|
|
| 91 |
<header className="ruled fade-up-1">
|
| 92 |
-
<h1 style={{ fontSize:
|
| 93 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 94 |
-
Aggregated patterns
|
| 95 |
</p>
|
| 96 |
</header>
|
| 97 |
|
| 98 |
-
{/*
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
<div
|
| 102 |
-
{
|
| 103 |
-
<p className="tabular font-bold" style={{ fontSize: 36, color, fontFamily: 'var(--font-mono)' }}>
|
| 104 |
-
{count}
|
| 105 |
-
</p>
|
| 106 |
-
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.12em' }}>
|
| 107 |
-
{label}
|
| 108 |
-
</p>
|
| 109 |
</div>
|
| 110 |
-
|
| 111 |
-
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
{!
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
)}
|
| 126 |
</main>
|
| 127 |
)
|
|
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
+
import { api } from '../api'
|
| 3 |
+
import { PAGE_STYLE } from '../App.jsx'
|
| 4 |
+
import { scoreColor } from '../utils/format.js'
|
| 5 |
+
import SkeletonCard from '../components/SkeletonCard.jsx'
|
| 6 |
+
import {
|
| 7 |
+
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
| 8 |
+
AreaChart, Area, CartesianGrid
|
| 9 |
+
} from 'recharts'
|
| 10 |
|
| 11 |
+
/* ββ Brand colors for chart series βββββββββββββββββββββββ */
|
| 12 |
+
const CHART_COLORS = ['#dc2626', '#d97706', '#06b6d4', '#8b5cf6', '#16a34a', '#ec4899', '#0ea5e9', '#f97316']
|
| 13 |
|
| 14 |
+
/* ββ Custom tooltip β editorial style ββββββββββββββββββββ */
|
| 15 |
const ChartTooltip = ({ active, payload }) => {
|
| 16 |
if (!active || !payload?.length) return null
|
| 17 |
+
const entry = payload[0]
|
| 18 |
return (
|
| 19 |
+
<div role="tooltip" aria-live="polite"
|
| 20 |
+
style={{
|
| 21 |
+
background: 'var(--bg-elevated)',
|
| 22 |
+
border: '1px solid var(--border)',
|
| 23 |
+
borderRadius: 2,
|
| 24 |
+
padding: '8px 12px',
|
| 25 |
+
}}>
|
| 26 |
<p className="text-xs font-bold" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
| 27 |
+
{entry.payload.name ?? entry.payload.topic}
|
| 28 |
</p>
|
| 29 |
+
<p className="tabular text-xs mt-0.5" style={{ color: entry.fill || 'var(--accent-cyan)', fontFamily: 'var(--font-mono)' }}>
|
| 30 |
+
{entry.value} verifications
|
| 31 |
</p>
|
| 32 |
</div>
|
| 33 |
)
|
| 34 |
}
|
| 35 |
|
| 36 |
+
/* ββ Section heading ββββββββββββββββββββββββββββββββββββ */
|
| 37 |
+
function SectionHeading({ children }) {
|
| 38 |
+
return (
|
| 39 |
+
<p className="text-xs font-semibold uppercase mb-4"
|
| 40 |
+
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
|
| 41 |
+
{children}
|
| 42 |
+
</p>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* ββ Bar chart section ββββββββββββββββββββββββββββββββββ */
|
| 47 |
+
function ChartSection({ title, data, dataKey, description }) {
|
| 48 |
if (!data?.length) return null
|
| 49 |
return (
|
| 50 |
<section aria-label={title} className="card p-5 fade-up-2">
|
| 51 |
+
<div className="mb-4">
|
| 52 |
+
<SectionHeading>{title}</SectionHeading>
|
| 53 |
+
{description && (
|
| 54 |
+
<p className="text-xs -mt-2 mb-4" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 55 |
+
{description}
|
| 56 |
+
</p>
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
<ResponsiveContainer width="100%" height={200}>
|
| 60 |
+
<BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 48 }}>
|
| 61 |
+
<CartesianGrid vertical={false} stroke="rgba(245,240,232,0.04)" />
|
| 62 |
<XAxis dataKey={dataKey}
|
| 63 |
+
tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
|
| 64 |
+
tickFormatter={(val) => {
|
| 65 |
+
if (!val) return ''
|
| 66 |
+
const s = String(val)
|
| 67 |
+
return s.length > 18 ? s.slice(0, 18) + 'β¦' : s
|
| 68 |
+
}}
|
| 69 |
+
angle={-30}
|
| 70 |
+
textAnchor="end"
|
| 71 |
+
interval={0}
|
| 72 |
axisLine={false} tickLine={false} />
|
| 73 |
<YAxis
|
| 74 |
+
tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
|
| 75 |
axisLine={false} tickLine={false} />
|
| 76 |
+
<Tooltip content={<ChartTooltip />} cursor={{ fill: 'rgba(245,240,232,0.03)' }} />
|
| 77 |
+
<Bar dataKey="count" radius={[2, 2, 0, 0]} maxBarSize={48}>
|
|
|
|
| 78 |
{data.map((_, i) => (
|
| 79 |
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
| 80 |
))}
|
|
|
|
| 85 |
)
|
| 86 |
}
|
| 87 |
|
| 88 |
+
/* ββ Verdict area chart (time-series if available) βββββββ */
|
| 89 |
+
function VerdictAreaChart({ data }) {
|
| 90 |
+
if (!data?.length) return null
|
| 91 |
+
return (
|
| 92 |
+
<section aria-label="Verdict trend" className="card p-5 fade-up-3">
|
| 93 |
+
<SectionHeading>Verdict Distribution Over Time</SectionHeading>
|
| 94 |
+
<ResponsiveContainer width="100%" height={160}>
|
| 95 |
+
<AreaChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
| 96 |
+
<defs>
|
| 97 |
+
<linearGradient id="fillCredible" x1="0" y1="0" x2="0" y2="1">
|
| 98 |
+
<stop offset="5%" stopColor="var(--credible)" stopOpacity={0.3} />
|
| 99 |
+
<stop offset="95%" stopColor="var(--credible)" stopOpacity={0} />
|
| 100 |
+
</linearGradient>
|
| 101 |
+
<linearGradient id="fillFake" x1="0" y1="0" x2="0" y2="1">
|
| 102 |
+
<stop offset="5%" stopColor="var(--fake)" stopOpacity={0.3} />
|
| 103 |
+
<stop offset="95%" stopColor="var(--fake)" stopOpacity={0} />
|
| 104 |
+
</linearGradient>
|
| 105 |
+
</defs>
|
| 106 |
+
<CartesianGrid vertical={false} stroke="rgba(245,240,232,0.04)" />
|
| 107 |
+
<XAxis dataKey="date"
|
| 108 |
+
tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
|
| 109 |
+
axisLine={false} tickLine={false} />
|
| 110 |
+
<YAxis
|
| 111 |
+
tick={{ fontSize: 10, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
|
| 112 |
+
axisLine={false} tickLine={false} />
|
| 113 |
+
<Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border)', strokeWidth: 1 }} />
|
| 114 |
+
<Area type="monotone" dataKey="credible" stroke="var(--credible)" fill="url(#fillCredible)" strokeWidth={2} />
|
| 115 |
+
<Area type="monotone" dataKey="fake" stroke="var(--fake)" fill="url(#fillFake)" strokeWidth={2} />
|
| 116 |
+
</AreaChart>
|
| 117 |
+
</ResponsiveContainer>
|
| 118 |
+
{/* Legend */}
|
| 119 |
+
<div className="flex gap-4 mt-3">
|
| 120 |
+
{[
|
| 121 |
+
{ color: 'var(--credible)', label: 'Credible' },
|
| 122 |
+
{ color: 'var(--fake)', label: 'False' },
|
| 123 |
+
].map(({ color, label }) => (
|
| 124 |
+
<div key={label} className="flex items-center gap-1.5">
|
| 125 |
+
<span className="w-3 h-0.5" style={{ background: color, display: 'inline-block' }} />
|
| 126 |
+
<span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.08em' }}>
|
| 127 |
+
{label}
|
| 128 |
+
</span>
|
| 129 |
+
</div>
|
| 130 |
+
))}
|
| 131 |
+
</div>
|
| 132 |
+
</section>
|
| 133 |
+
)
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
export default function TrendsPage() {
|
| 137 |
const [data, setData] = useState(null)
|
| 138 |
const [loading, setLoading] = useState(true)
|
|
|
|
| 145 |
.finally(() => setLoading(false))
|
| 146 |
}, [])
|
| 147 |
|
| 148 |
+
/* ββ Derived data βββββββββββββββββββββββββββββββββββββββ */
|
| 149 |
+
// top_entities is an array of { entity, entity_type, count, fake_count, fake_ratio }
|
| 150 |
+
const entityData = (data?.top_entities || [])
|
| 151 |
+
.slice(0, 8)
|
| 152 |
+
.map(e => ({ name: e.entity, count: e.count }))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
// top_topics is an array of { topic, count, dominant_verdict }
|
| 155 |
+
const topicData = (data?.top_topics || []).slice(0, 8)
|
| 156 |
|
| 157 |
const verdicts = [
|
| 158 |
{ label: 'VERIFIED', count: data?.verdict_distribution?.Credible ?? 0, color: 'var(--credible)' },
|
| 159 |
{ label: 'UNVERIFIED', count: data?.verdict_distribution?.Unverified ?? 0, color: 'var(--unverified)' },
|
| 160 |
{ label: 'FALSE', count: data?.verdict_distribution?.['Likely Fake'] ?? 0, color: 'var(--fake)' },
|
| 161 |
]
|
| 162 |
+
const total = verdicts.reduce((s, v) => s + v.count, 0)
|
| 163 |
const hasData = entityData.length > 0 || verdicts.some(v => v.count > 0)
|
| 164 |
|
| 165 |
return (
|
| 166 |
+
<main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
| 167 |
+
|
| 168 |
+
{/* ββ Page header βββββββββββββββββββββββββββββ */}
|
| 169 |
<header className="ruled fade-up-1">
|
| 170 |
+
<h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>Trends</h1>
|
| 171 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 172 |
+
Aggregated misinformation patterns across all verified claims
|
| 173 |
</p>
|
| 174 |
</header>
|
| 175 |
|
| 176 |
+
{/* ββ Loading ββββββββββββββββββββββββββββββββββ */}
|
| 177 |
+
{loading && (
|
| 178 |
+
<div className="space-y-4" aria-live="polite" aria-label="Loading trends">
|
| 179 |
+
<div className="grid grid-cols-3 gap-3">
|
| 180 |
+
{[0, 1, 2].map(i => <SkeletonCard key={i} height={96} />)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
</div>
|
| 182 |
+
<SkeletonCard height={220} />
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
|
| 186 |
+
{/* ββ Error ββββββββββββββββββββββββββββββββββββ */}
|
| 187 |
+
{error && !loading && (
|
| 188 |
+
<p role="alert" className="text-center py-12 text-sm" style={{ color: '#f87171' }}>
|
| 189 |
+
Error loading trends: {error}
|
| 190 |
+
</p>
|
| 191 |
+
)}
|
| 192 |
|
| 193 |
+
{!loading && !error && (
|
| 194 |
+
<>
|
| 195 |
+
{/* ββ Impact stats ββββββββββββββββββββββββββββββββββββββββββ */}
|
| 196 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 fade-up-1" role="list" aria-label="Verdict distribution">
|
| 197 |
+
{verdicts.map(({ label, count, color }) => {
|
| 198 |
+
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
| 199 |
+
return (
|
| 200 |
+
<div key={label} className="card p-5" role="listitem"
|
| 201 |
+
style={{ borderTop: `3px solid ${color}` }}>
|
| 202 |
+
{/* Large impact number β interactive-portfolio pattern */}
|
| 203 |
+
<p className="tabular font-bold" style={{ fontSize: 40, color, fontFamily: 'var(--font-mono)', lineHeight: 1 }}>
|
| 204 |
+
{count}
|
| 205 |
+
</p>
|
| 206 |
+
<p className="mt-2 text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.12em' }}>
|
| 207 |
+
{label}
|
| 208 |
+
</p>
|
| 209 |
+
{total > 0 && (
|
| 210 |
+
<div className="mt-2 h-1" style={{ background: 'var(--bg-hover)', borderRadius: 1 }}>
|
| 211 |
+
<div className="h-1 bar-fill" style={{ width: `${pct}%`, background: color, borderRadius: 1 }} />
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
</div>
|
| 215 |
+
)
|
| 216 |
+
})}
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* ββ Charts βββββββββββββββββββββββββββββββ */}
|
| 220 |
+
<ChartSection
|
| 221 |
+
title="Top Named Entities"
|
| 222 |
+
data={entityData}
|
| 223 |
+
dataKey="name"
|
| 224 |
+
description="Most frequently appearing persons, organizations, and places in verified claims"
|
| 225 |
+
/>
|
| 226 |
+
|
| 227 |
+
<ChartSection
|
| 228 |
+
title="Top Fake News Topics"
|
| 229 |
+
data={topicData}
|
| 230 |
+
dataKey="topic"
|
| 231 |
+
description="Recurring misinformation topics detected across false claims"
|
| 232 |
+
/>
|
| 233 |
+
|
| 234 |
+
{/* ββ Verdict trend over time ββββββββββββββββββββ */}
|
| 235 |
+
<VerdictAreaChart data={data?.verdict_by_day || []} />
|
| 236 |
+
|
| 237 |
+
{/* ββ Empty state ββββββββββββββββββββββ */}
|
| 238 |
+
{!hasData && (
|
| 239 |
+
<div className="card p-16 text-center fade-up">
|
| 240 |
+
<p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18 }}>
|
| 241 |
+
No trend data yet
|
| 242 |
+
</p>
|
| 243 |
+
<p className="text-sm mt-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 244 |
+
Run some verifications first β patterns will emerge here as data accumulates.
|
| 245 |
+
</p>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</>
|
| 249 |
)}
|
| 250 |
</main>
|
| 251 |
)
|
frontend/src/pages/VerifyPage.jsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
-
import { useState, useRef, useId } from 'react'
|
| 2 |
-
import { api } from '../api
|
| 3 |
-
import { scoreColor } from '../utils/format.js'
|
|
|
|
| 4 |
import ScoreGauge from '../components/ScoreGauge.jsx'
|
| 5 |
import VerdictBadge from '../components/VerdictBadge.jsx'
|
| 6 |
-
import
|
|
|
|
|
|
|
| 7 |
|
| 8 |
/* ββ Tab definitions ββββββββββββββββββββββββββββββββββββββ */
|
| 9 |
const TABS = [
|
|
@@ -13,12 +16,29 @@ const TABS = [
|
|
| 13 |
{ id: 'video', icon: Video, label: 'Video' },
|
| 14 |
]
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
/* ββ Atomic sub-components (architect-review: Single Responsibility) ββ */
|
| 17 |
-
function SectionHeading({ children }) {
|
| 18 |
return (
|
| 19 |
-
<p className="font-display text-xs font-semibold uppercase tracking-widest mb-3"
|
| 20 |
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
|
| 21 |
{children}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</p>
|
| 23 |
)
|
| 24 |
}
|
|
@@ -44,39 +64,239 @@ function ScoreBar({ label, value, color, index = 0 }) {
|
|
| 44 |
<span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span>
|
| 45 |
<span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span>
|
| 46 |
</div>
|
| 47 |
-
<div className="h-1 rounded-none" style={{ background: 'var(--bg-hover)' }}
|
| 48 |
role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100}
|
| 49 |
aria-label={label}>
|
| 50 |
-
<div className="h-1 bar-fill"
|
| 51 |
style={{
|
| 52 |
width: `${value}%`,
|
| 53 |
background: color,
|
| 54 |
-
animationDelay: `${index *
|
|
|
|
| 55 |
}} />
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
)
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
/* ββ Main Page ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 62 |
export default function VerifyPage() {
|
| 63 |
-
const
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
const [file, setFile] = useState(null)
|
|
|
|
| 66 |
const [loading, setLoading] = useState(false)
|
| 67 |
-
const [result, setResult] = useState(null)
|
| 68 |
const [error, setError] = useState(null)
|
|
|
|
|
|
|
|
|
|
| 69 |
const fileRef = useRef()
|
| 70 |
-
|
| 71 |
const inputId = useId()
|
| 72 |
const errorId = useId()
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file)
|
| 75 |
|
| 76 |
async function handleSubmit(e) {
|
| 77 |
e.preventDefault()
|
| 78 |
if (!canSubmit) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
setLoading(true); setError(null); setResult(null)
|
|
|
|
| 80 |
try {
|
| 81 |
let res
|
| 82 |
if (tab === 'text') res = await api.verifyText(input)
|
|
@@ -85,42 +305,83 @@ export default function VerifyPage() {
|
|
| 85 |
else res = await api.verifyVideo(file)
|
| 86 |
setResult(res)
|
| 87 |
} catch (err) {
|
| 88 |
-
setError(err.message)
|
| 89 |
} finally {
|
| 90 |
setLoading(false)
|
| 91 |
}
|
| 92 |
}
|
| 93 |
|
| 94 |
function handleTabChange(id) {
|
| 95 |
-
setTab(id); setInput(''); setFile(null); setResult(null); setError(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
const entities = result?.entities || {}
|
| 99 |
const allEntities = [
|
| 100 |
-
...(entities.persons || []).map(e => ({ label: e, type: 'Person' })),
|
| 101 |
-
...(entities.organizations || []).map(e => ({ label: e, type: 'Org' })),
|
| 102 |
-
...(entities.locations || []).map(e => ({ label: e, type: 'Place' })),
|
| 103 |
-
...(entities.dates || []).map(e => ({ label: e, type: 'Date' })),
|
| 104 |
]
|
| 105 |
|
| 106 |
const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)'
|
|
|
|
| 107 |
|
| 108 |
return (
|
| 109 |
-
<main
|
| 110 |
-
|
|
|
|
| 111 |
<header className="ruled fade-up-1">
|
| 112 |
-
<h1 style={{ fontSize:
|
| 113 |
-
Fact Check
|
| 114 |
-
</h1>
|
| 115 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 116 |
Paste text, a URL, or upload media β we'll verify credibility instantly.
|
| 117 |
</p>
|
| 118 |
</header>
|
| 119 |
|
| 120 |
-
{/* Input card */}
|
| 121 |
-
<section aria-label="Input panel" className="card p-6 space-y-4 fade-up-2">
|
| 122 |
-
{/* Tab bar
|
| 123 |
-
<div role="tablist" aria-label="Input type" className="flex gap-1">
|
| 124 |
{TABS.map(({ id, icon: Icon, label }) => {
|
| 125 |
const active = tab === id
|
| 126 |
return (
|
|
@@ -130,7 +391,7 @@ export default function VerifyPage() {
|
|
| 130 |
aria-controls={`panel-${id}`}
|
| 131 |
id={`tab-${id}`}
|
| 132 |
onClick={() => handleTabChange(id)}
|
| 133 |
-
className="flex items-center gap-1.5 px-3 py-
|
| 134 |
style={{
|
| 135 |
fontFamily: 'var(--font-display)',
|
| 136 |
letterSpacing: '0.08em',
|
|
@@ -139,6 +400,7 @@ export default function VerifyPage() {
|
|
| 139 |
border: 'none',
|
| 140 |
cursor: 'pointer',
|
| 141 |
borderRadius: 2,
|
|
|
|
| 142 |
}}>
|
| 143 |
<Icon size={12} aria-hidden="true" />
|
| 144 |
{label.toUpperCase()}
|
|
@@ -149,9 +411,9 @@ export default function VerifyPage() {
|
|
| 149 |
|
| 150 |
<form onSubmit={handleSubmit} className="space-y-4"
|
| 151 |
aria-describedby={error ? errorId : undefined}>
|
| 152 |
-
|
| 153 |
{(tab === 'text' || tab === 'url') ? (
|
| 154 |
-
<div>
|
| 155 |
<label htmlFor={inputId} className="sr-only">
|
| 156 |
{tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'}
|
| 157 |
</label>
|
|
@@ -159,27 +421,33 @@ export default function VerifyPage() {
|
|
| 159 |
id={inputId}
|
| 160 |
value={input}
|
| 161 |
onChange={e => setInput(e.target.value)}
|
| 162 |
-
placeholder={tab === 'url'
|
|
|
|
|
|
|
| 163 |
rows={tab === 'url' ? 2 : 5}
|
| 164 |
-
/* web-design-guidelines: autocomplete + type */
|
| 165 |
autoComplete="off"
|
| 166 |
spellCheck={tab === 'url' ? 'false' : 'true'}
|
| 167 |
-
|
|
|
|
| 168 |
style={{
|
| 169 |
background: 'var(--bg-elevated)',
|
| 170 |
border: '1px solid var(--border)',
|
| 171 |
color: 'var(--text-primary)',
|
| 172 |
fontFamily: 'var(--font-body)',
|
| 173 |
borderRadius: 2,
|
| 174 |
-
|
| 175 |
}}
|
| 176 |
onFocus={e => e.target.style.borderColor = 'var(--accent-red)'}
|
| 177 |
onBlur={e => e.target.style.borderColor = 'var(--border)'}
|
| 178 |
aria-label={tab === 'url' ? 'URL input' : 'Claim text input'}
|
| 179 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
</div>
|
| 181 |
) : (
|
| 182 |
-
/*
|
| 183 |
<div>
|
| 184 |
<label htmlFor={`file-${tab}`} className="sr-only">
|
| 185 |
Upload {tab === 'image' ? 'an image' : 'a video or audio file'}
|
|
@@ -187,29 +455,41 @@ export default function VerifyPage() {
|
|
| 187 |
<div
|
| 188 |
onClick={() => fileRef.current?.click()}
|
| 189 |
onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
tabIndex={0}
|
| 191 |
role="button"
|
| 192 |
aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`}
|
| 193 |
-
className="p-10 text-center cursor-pointer transition-
|
| 194 |
style={{
|
| 195 |
-
background: 'var(--bg-elevated)',
|
| 196 |
-
border: `1px dashed ${file ? 'var(--accent-red)' : 'var(--border)'}`,
|
| 197 |
borderRadius: 2,
|
|
|
|
| 198 |
}}>
|
| 199 |
<input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only"
|
|
|
|
| 200 |
accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'}
|
| 201 |
onChange={e => setFile(e.target.files[0])} />
|
|
|
|
|
|
|
| 202 |
{file
|
| 203 |
? <p className="text-sm font-semibold" style={{ color: 'var(--accent-red)', fontFamily: 'var(--font-display)' }}>{file.name}</p>
|
| 204 |
-
: <
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
}
|
| 208 |
</div>
|
| 209 |
</div>
|
| 210 |
)}
|
| 211 |
|
| 212 |
-
{/* Submit β web-design-guidelines: specific button label, spinner during request */}
|
| 213 |
<button type="submit" disabled={!canSubmit}
|
| 214 |
className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors"
|
| 215 |
style={{
|
|
@@ -220,6 +500,7 @@ export default function VerifyPage() {
|
|
| 220 |
border: 'none',
|
| 221 |
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
| 222 |
borderRadius: 2,
|
|
|
|
| 223 |
}}
|
| 224 |
aria-busy={loading}>
|
| 225 |
{loading
|
|
@@ -230,7 +511,90 @@ export default function VerifyPage() {
|
|
| 230 |
</form>
|
| 231 |
</section>
|
| 232 |
|
| 233 |
-
{/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
{error && (
|
| 235 |
<div id={errorId} role="alert"
|
| 236 |
className="card p-4 flex items-start gap-2"
|
|
@@ -241,61 +605,145 @@ export default function VerifyPage() {
|
|
| 241 |
Verification failed
|
| 242 |
</p>
|
| 243 |
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 244 |
-
{error}
|
|
|
|
|
|
|
|
|
|
| 245 |
</p>
|
| 246 |
</div>
|
| 247 |
</div>
|
| 248 |
)}
|
| 249 |
|
| 250 |
-
{/*
|
| 251 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
<section aria-label="Verification results" className="space-y-4">
|
| 253 |
|
| 254 |
-
{/*
|
| 255 |
-
<div className="
|
| 256 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
<div className="card p-5 flex flex-col items-center justify-center gap-3">
|
| 258 |
<ScoreGauge score={result.final_score} size={140} />
|
| 259 |
<VerdictBadge verdict={result.verdict} size="banner" />
|
| 260 |
</div>
|
| 261 |
-
|
| 262 |
-
{/* Meta panel */}
|
| 263 |
<div className="card p-5 fade-up-2">
|
| 264 |
<SectionHeading>Analysis Details</SectionHeading>
|
| 265 |
<MetaRow label="Language" value={result.language} />
|
| 266 |
<MetaRow label="Sentiment" value={result.sentiment} />
|
| 267 |
<MetaRow label="Emotion" value={result.emotion} />
|
| 268 |
-
<MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`}
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
</div>
|
| 273 |
</div>
|
| 274 |
|
| 275 |
-
{/* Score breakdown */}
|
| 276 |
<div className="card p-5 fade-up-3">
|
| 277 |
<SectionHeading>Score Breakdown</SectionHeading>
|
| 278 |
<div className="space-y-4">
|
| 279 |
-
<ScoreBar label="ML Classifier (Layer 1)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
|
| 280 |
-
<ScoreBar label="Evidence Score (Layer 2)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
|
| 281 |
<ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
|
| 282 |
</div>
|
| 283 |
</div>
|
| 284 |
|
| 285 |
-
{/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
{allEntities.length > 0 && (
|
| 287 |
-
<div className="card p-5 fade-up-
|
| 288 |
-
<SectionHeading
|
| 289 |
<ul className="flex flex-wrap gap-2" role="list">
|
| 290 |
{allEntities.map((e, i) => (
|
| 291 |
<li key={i}
|
| 292 |
className="flex items-center gap-1.5 px-2.5 py-1 text-xs"
|
| 293 |
style={{
|
| 294 |
background: 'var(--bg-elevated)',
|
| 295 |
-
border:
|
| 296 |
borderRadius: 2,
|
| 297 |
}}>
|
| 298 |
-
<span style={{ color:
|
| 299 |
{e.type.toUpperCase()}
|
| 300 |
</span>
|
| 301 |
<span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span>
|
|
@@ -305,25 +753,50 @@ export default function VerifyPage() {
|
|
| 305 |
</div>
|
| 306 |
)}
|
| 307 |
|
| 308 |
-
{/* Evidence
|
| 309 |
{result.layer2?.sources?.length > 0 && (
|
| 310 |
<div className="card p-5 fade-up-5">
|
| 311 |
-
<SectionHeading>Evidence Sources</SectionHeading>
|
| 312 |
<ul className="space-y-2" role="list">
|
| 313 |
-
{result.layer2.sources.map((src, i) =>
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
{
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
</ul>
|
| 328 |
</div>
|
| 329 |
)}
|
|
|
|
| 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 = [
|
|
|
|
| 16 |
{ id: 'video', icon: Video, label: 'Video' },
|
| 17 |
]
|
| 18 |
|
| 19 |
+
/* ββ Stance icon map ββββββββββββββββββββββββββββββββββββββββ */
|
| 20 |
+
const STANCE_ICON = {
|
| 21 |
+
'Supports': { Icon: CheckCircle2, color: 'var(--credible)' },
|
| 22 |
+
'Refutes': { Icon: XCircle, color: 'var(--fake)' },
|
| 23 |
+
'Not Enough Info': { Icon: HelpCircle, color: 'var(--text-muted)' },
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
/* ββ Atomic sub-components (architect-review: Single Responsibility) ββ */
|
| 27 |
+
function SectionHeading({ children, count }) {
|
| 28 |
return (
|
| 29 |
+
<p className="font-display text-xs font-semibold uppercase tracking-widest mb-3 flex items-center gap-2"
|
| 30 |
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
|
| 31 |
{children}
|
| 32 |
+
{count !== undefined && (
|
| 33 |
+
<span style={{
|
| 34 |
+
background: 'var(--bg-hover)',
|
| 35 |
+
color: 'var(--text-secondary)',
|
| 36 |
+
fontFamily: 'var(--font-mono)',
|
| 37 |
+
fontSize: 10,
|
| 38 |
+
padding: '1px 6px',
|
| 39 |
+
borderRadius: 2,
|
| 40 |
+
}}>{count}</span>
|
| 41 |
+
)}
|
| 42 |
</p>
|
| 43 |
)
|
| 44 |
}
|
|
|
|
| 64 |
<span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span>
|
| 65 |
<span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span>
|
| 66 |
</div>
|
| 67 |
+
<div className="h-1.5 rounded-none" style={{ background: 'var(--bg-hover)' }}
|
| 68 |
role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100}
|
| 69 |
aria-label={label}>
|
| 70 |
+
<div className="h-1.5 bar-fill"
|
| 71 |
style={{
|
| 72 |
width: `${value}%`,
|
| 73 |
background: color,
|
| 74 |
+
animationDelay: `${index * 120}ms`,
|
| 75 |
+
borderRadius: 1,
|
| 76 |
}} />
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
)
|
| 80 |
}
|
| 81 |
|
| 82 |
+
/** Layer verdict detail card β for both Layer 1 and Layer 2 */
|
| 83 |
+
function LayerCard({ title, icon: HeaderIcon, verdict, score, children, delay = 0 }) {
|
| 84 |
+
const { cls } = VERDICT_MAP[verdict] ?? VERDICT_MAP['Unverified']
|
| 85 |
+
return (
|
| 86 |
+
<div className="card p-5 fade-up" style={{ animationDelay: `${delay}ms` }}>
|
| 87 |
+
<div className="flex items-center justify-between mb-4">
|
| 88 |
+
<div className="flex items-center gap-2">
|
| 89 |
+
<HeaderIcon size={13} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
|
| 90 |
+
<SectionHeading>{title}</SectionHeading>
|
| 91 |
+
</div>
|
| 92 |
+
<VerdictBadge verdict={verdict} size="sm" />
|
| 93 |
+
</div>
|
| 94 |
+
{score !== undefined && (
|
| 95 |
+
<ScoreBar label="Confidence" value={score} color={scoreColor(score)} index={0} />
|
| 96 |
+
)}
|
| 97 |
+
{children && <div className="mt-4">{children}</div>}
|
| 98 |
+
</div>
|
| 99 |
+
)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/** Triggered features feature breakdown chart */
|
| 103 |
+
function FeatureBreakdown({ features }) {
|
| 104 |
+
if (!features?.length) return (
|
| 105 |
+
<p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 106 |
+
No suspicious features detected
|
| 107 |
+
</p>
|
| 108 |
+
)
|
| 109 |
+
return (
|
| 110 |
+
<ul className="flex flex-wrap gap-1.5" role="list" aria-label="Triggered suspicious features">
|
| 111 |
+
{features.map((f, i) => (
|
| 112 |
+
<li key={i}
|
| 113 |
+
className="text-xs px-2 py-1"
|
| 114 |
+
style={{
|
| 115 |
+
background: 'rgba(220,38,38,0.1)',
|
| 116 |
+
color: '#f87171',
|
| 117 |
+
border: '1px solid rgba(220,38,38,0.25)',
|
| 118 |
+
borderRadius: 2,
|
| 119 |
+
fontFamily: 'var(--font-display)',
|
| 120 |
+
letterSpacing: '0.04em',
|
| 121 |
+
}}>
|
| 122 |
+
{f}
|
| 123 |
+
</li>
|
| 124 |
+
))}
|
| 125 |
+
</ul>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
/* ββ URL article preview card βββββββββββββββββββββββββββββ */
|
| 129 |
+
function URLPreviewCard({ preview, loading, url }) {
|
| 130 |
+
if (loading && !preview) {
|
| 131 |
+
return (
|
| 132 |
+
<div className="flex items-center gap-2 px-3 py-2"
|
| 133 |
+
style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 2 }}>
|
| 134 |
+
<Loader2 size={11} className="animate-spin" style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
|
| 135 |
+
<span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>Fetching article previewβ¦</span>
|
| 136 |
+
</div>
|
| 137 |
+
)
|
| 138 |
+
}
|
| 139 |
+
if (!preview) return null
|
| 140 |
+
return (
|
| 141 |
+
<a href={url} target="_blank" rel="noreferrer"
|
| 142 |
+
className="flex gap-3 p-3 transition-colors"
|
| 143 |
+
style={{
|
| 144 |
+
background: 'var(--bg-elevated)',
|
| 145 |
+
border: '1px solid var(--border)',
|
| 146 |
+
borderRadius: 2,
|
| 147 |
+
textDecoration: 'none',
|
| 148 |
+
display: 'flex',
|
| 149 |
+
}}
|
| 150 |
+
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'}
|
| 151 |
+
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
| 152 |
+
{/* Thumbnail */}
|
| 153 |
+
{preview.image && (
|
| 154 |
+
<img
|
| 155 |
+
src={preview.image}
|
| 156 |
+
alt=""
|
| 157 |
+
aria-hidden="true"
|
| 158 |
+
onError={e => { e.currentTarget.style.display = 'none' }}
|
| 159 |
+
style={{
|
| 160 |
+
width: 72, height: 56,
|
| 161 |
+
objectFit: 'cover',
|
| 162 |
+
borderRadius: 2,
|
| 163 |
+
flexShrink: 0,
|
| 164 |
+
border: '1px solid var(--border)',
|
| 165 |
+
}} />
|
| 166 |
+
)}
|
| 167 |
+
<div className="flex-1 min-w-0">
|
| 168 |
+
{/* Source row */}
|
| 169 |
+
<div className="flex items-center gap-1.5 mb-1">
|
| 170 |
+
{preview.favicon && (
|
| 171 |
+
<img src={preview.favicon} alt="" aria-hidden="true"
|
| 172 |
+
onError={e => { e.currentTarget.style.display = 'none' }}
|
| 173 |
+
style={{ width: 12, height: 12, borderRadius: 2, flexShrink: 0 }} />
|
| 174 |
+
)}
|
| 175 |
+
<span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: 10 }}>
|
| 176 |
+
{preview.site_name || preview.domain}
|
| 177 |
+
</span>
|
| 178 |
+
<ExternalLink size={9} style={{ color: 'var(--text-muted)', flexShrink: 0, marginLeft: 'auto' }} aria-hidden="true" />
|
| 179 |
+
</div>
|
| 180 |
+
{/* Title */}
|
| 181 |
+
{preview.title && (
|
| 182 |
+
<p className="text-sm font-semibold"
|
| 183 |
+
style={{
|
| 184 |
+
color: 'var(--text-primary)',
|
| 185 |
+
fontFamily: 'var(--font-body)',
|
| 186 |
+
lineHeight: 1.4,
|
| 187 |
+
display: '-webkit-box',
|
| 188 |
+
WebkitLineClamp: 2,
|
| 189 |
+
WebkitBoxOrient: 'vertical',
|
| 190 |
+
overflow: 'hidden',
|
| 191 |
+
}}>
|
| 192 |
+
{preview.title}
|
| 193 |
+
</p>
|
| 194 |
+
)}
|
| 195 |
+
{/* Description */}
|
| 196 |
+
{preview.description && (
|
| 197 |
+
<p className="text-xs mt-0.5"
|
| 198 |
+
style={{
|
| 199 |
+
color: 'var(--text-secondary)',
|
| 200 |
+
fontFamily: 'var(--font-body)',
|
| 201 |
+
lineHeight: 1.5,
|
| 202 |
+
display: '-webkit-box',
|
| 203 |
+
WebkitLineClamp: 2,
|
| 204 |
+
WebkitBoxOrient: 'vertical',
|
| 205 |
+
overflow: 'hidden',
|
| 206 |
+
}}>
|
| 207 |
+
{preview.description}
|
| 208 |
+
</p>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
</a>
|
| 212 |
+
)
|
| 213 |
+
}
|
| 214 |
+
/* ββ SessionStorage persistence key βββββββββββββββββββββββ */
|
| 215 |
+
const STORAGE_KEY = 'philverify_verify_state'
|
| 216 |
+
|
| 217 |
+
function loadPersistedState() {
|
| 218 |
+
try {
|
| 219 |
+
const raw = sessionStorage.getItem(STORAGE_KEY)
|
| 220 |
+
if (!raw) return null
|
| 221 |
+
return JSON.parse(raw)
|
| 222 |
+
} catch {
|
| 223 |
+
return null
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function saveState(state) {
|
| 228 |
+
try {
|
| 229 |
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
| 230 |
+
} catch { /* quota exceeded β ignore */ }
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
/* ββ Main Page ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 234 |
export default function VerifyPage() {
|
| 235 |
+
const persisted = loadPersistedState()
|
| 236 |
+
|
| 237 |
+
const [tab, setTab] = useState(persisted?.tab ?? 'text')
|
| 238 |
+
const [input, setInput] = useState(persisted?.input ?? '')
|
| 239 |
const [file, setFile] = useState(null)
|
| 240 |
+
const [dragOver, setDragOver] = useState(false)
|
| 241 |
const [loading, setLoading] = useState(false)
|
| 242 |
+
const [result, setResult] = useState(persisted?.result ?? null)
|
| 243 |
const [error, setError] = useState(null)
|
| 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()
|
| 250 |
const errorId = useId()
|
| 251 |
|
| 252 |
+
/* Revoke object URLs when submittedInput changes to avoid memory leaks */
|
| 253 |
+
useEffect(() => {
|
| 254 |
+
return () => {
|
| 255 |
+
if (submittedInput?.fileUrl) URL.revokeObjectURL(submittedInput.fileUrl)
|
| 256 |
+
}
|
| 257 |
+
}, [submittedInput])
|
| 258 |
+
|
| 259 |
+
/* Persist result + input to sessionStorage so state survives navigation/refresh */
|
| 260 |
+
useEffect(() => {
|
| 261 |
+
if (result) {
|
| 262 |
+
// Strip non-serialisable file references before saving
|
| 263 |
+
const serializableSubmittedInput = submittedInput
|
| 264 |
+
? { type: submittedInput.type, text: submittedInput.text, preview: submittedInput.preview ?? null }
|
| 265 |
+
: null
|
| 266 |
+
saveState({ tab, input, result, submittedInput: serializableSubmittedInput })
|
| 267 |
+
}
|
| 268 |
+
}, [result, submittedInput, tab, input])
|
| 269 |
+
|
| 270 |
+
/* Debounced URL preview β fetches OG metadata 600ms after typing stops */
|
| 271 |
+
useEffect(() => {
|
| 272 |
+
if (tab !== 'url' || !input.trim()) { setUrlPreview(null); setUrlPreviewLoading(false); return }
|
| 273 |
+
try { new URL(input.trim()) } catch { setUrlPreview(null); setUrlPreviewLoading(false); return }
|
| 274 |
+
setUrlPreviewLoading(true)
|
| 275 |
+
const timer = setTimeout(async () => {
|
| 276 |
+
try {
|
| 277 |
+
const preview = await api.preview(input.trim())
|
| 278 |
+
setUrlPreview(preview)
|
| 279 |
+
} catch {
|
| 280 |
+
setUrlPreview(null)
|
| 281 |
+
} finally {
|
| 282 |
+
setUrlPreviewLoading(false)
|
| 283 |
+
}
|
| 284 |
+
}, 600)
|
| 285 |
+
return () => { clearTimeout(timer); setUrlPreviewLoading(false) }
|
| 286 |
+
}, [tab, input])
|
| 287 |
+
|
| 288 |
const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file)
|
| 289 |
|
| 290 |
async function handleSubmit(e) {
|
| 291 |
e.preventDefault()
|
| 292 |
if (!canSubmit) return
|
| 293 |
+
/* Capture what the user submitted before any state resets */
|
| 294 |
+
const previewUrl = (tab === 'image' || tab === 'video') && file
|
| 295 |
+
? URL.createObjectURL(file)
|
| 296 |
+
: null
|
| 297 |
+
setSubmittedInput({ type: tab, text: input, file: file, fileUrl: previewUrl, preview: tab === 'url' ? urlPreview : null })
|
| 298 |
setLoading(true); setError(null); setResult(null)
|
| 299 |
+
sessionStorage.removeItem(STORAGE_KEY)
|
| 300 |
try {
|
| 301 |
let res
|
| 302 |
if (tab === 'text') res = await api.verifyText(input)
|
|
|
|
| 305 |
else res = await api.verifyVideo(file)
|
| 306 |
setResult(res)
|
| 307 |
} catch (err) {
|
| 308 |
+
setError(typeof err.message === 'string' ? err.message : String(err))
|
| 309 |
} finally {
|
| 310 |
setLoading(false)
|
| 311 |
}
|
| 312 |
}
|
| 313 |
|
| 314 |
function handleTabChange(id) {
|
| 315 |
+
setTab(id); setInput(''); setFile(null); setResult(null); setError(null); setSubmittedInput(null); setUrlPreview(null)
|
| 316 |
+
sessionStorage.removeItem(STORAGE_KEY)
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function handleVerifyAgain() {
|
| 320 |
+
setResult(null); setError(null)
|
| 321 |
+
sessionStorage.removeItem(STORAGE_KEY)
|
| 322 |
+
// Smooth-scroll back to the input panel
|
| 323 |
+
requestAnimationFrame(() => {
|
| 324 |
+
inputSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 325 |
+
})
|
| 326 |
}
|
| 327 |
|
| 328 |
+
/* Drag-and-drop handlers */
|
| 329 |
+
const handleDrop = useCallback((e) => {
|
| 330 |
+
e.preventDefault(); setDragOver(false)
|
| 331 |
+
const dropped = e.dataTransfer.files[0]
|
| 332 |
+
if (dropped) setFile(dropped)
|
| 333 |
+
}, [])
|
| 334 |
+
|
| 335 |
+
/* Paste handler β reads the first file/image item from clipboard */
|
| 336 |
+
const handlePaste = useCallback((e) => {
|
| 337 |
+
if (tab !== 'image' && tab !== 'video') return
|
| 338 |
+
const items = e.clipboardData?.items
|
| 339 |
+
if (!items) return
|
| 340 |
+
for (const item of items) {
|
| 341 |
+
if (item.kind === 'file') {
|
| 342 |
+
const pasted = item.getAsFile()
|
| 343 |
+
if (pasted) {
|
| 344 |
+
e.preventDefault()
|
| 345 |
+
setFile(pasted)
|
| 346 |
+
return
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
}, [tab])
|
| 351 |
+
|
| 352 |
+
/* Global paste listener β works even when the drop zone isn't focused */
|
| 353 |
+
useEffect(() => {
|
| 354 |
+
if (tab !== 'image' && tab !== 'video') return
|
| 355 |
+
document.addEventListener('paste', handlePaste)
|
| 356 |
+
return () => document.removeEventListener('paste', handlePaste)
|
| 357 |
+
}, [tab, handlePaste])
|
| 358 |
+
|
| 359 |
const entities = result?.entities || {}
|
| 360 |
const allEntities = [
|
| 361 |
+
...(entities.persons || []).map(e => ({ label: e, type: 'Person', color: 'var(--accent-cyan)' })),
|
| 362 |
+
...(entities.organizations || []).map(e => ({ label: e, type: 'Org', color: 'var(--accent-gold)' })),
|
| 363 |
+
...(entities.locations || []).map(e => ({ label: e, type: 'Place', color: '#8b5cf6' })),
|
| 364 |
+
...(entities.dates || []).map(e => ({ label: e, type: 'Date', color: 'var(--text-muted)' })),
|
| 365 |
]
|
| 366 |
|
| 367 |
const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)'
|
| 368 |
+
const triggerWords = result?.layer1?.triggered_features ?? []
|
| 369 |
|
| 370 |
return (
|
| 371 |
+
<main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
| 372 |
+
|
| 373 |
+
{/* ββ Page header βββββββββββββββββββββββββββββββ */}
|
| 374 |
<header className="ruled fade-up-1">
|
| 375 |
+
<h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>Fact Check</h1>
|
|
|
|
|
|
|
| 376 |
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 377 |
Paste text, a URL, or upload media β we'll verify credibility instantly.
|
| 378 |
</p>
|
| 379 |
</header>
|
| 380 |
|
| 381 |
+
{/* ββ Input card ββββββββββββββββββββββββββββββββ */}
|
| 382 |
+
<section ref={inputSectionRef} aria-label="Input panel" className="card p-6 space-y-4 fade-up-2">
|
| 383 |
+
{/* Tab bar */}
|
| 384 |
+
<div role="tablist" aria-label="Input type" className="flex gap-1.5 flex-wrap">
|
| 385 |
{TABS.map(({ id, icon: Icon, label }) => {
|
| 386 |
const active = tab === id
|
| 387 |
return (
|
|
|
|
| 391 |
aria-controls={`panel-${id}`}
|
| 392 |
id={`tab-${id}`}
|
| 393 |
onClick={() => handleTabChange(id)}
|
| 394 |
+
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold transition-colors"
|
| 395 |
style={{
|
| 396 |
fontFamily: 'var(--font-display)',
|
| 397 |
letterSpacing: '0.08em',
|
|
|
|
| 400 |
border: 'none',
|
| 401 |
cursor: 'pointer',
|
| 402 |
borderRadius: 2,
|
| 403 |
+
minHeight: 36, /* touch target */
|
| 404 |
}}>
|
| 405 |
<Icon size={12} aria-hidden="true" />
|
| 406 |
{label.toUpperCase()}
|
|
|
|
| 411 |
|
| 412 |
<form onSubmit={handleSubmit} className="space-y-4"
|
| 413 |
aria-describedby={error ? errorId : undefined}>
|
| 414 |
+
|
| 415 |
{(tab === 'text' || tab === 'url') ? (
|
| 416 |
+
<div className="space-y-2">
|
| 417 |
<label htmlFor={inputId} className="sr-only">
|
| 418 |
{tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'}
|
| 419 |
</label>
|
|
|
|
| 421 |
id={inputId}
|
| 422 |
value={input}
|
| 423 |
onChange={e => setInput(e.target.value)}
|
| 424 |
+
placeholder={tab === 'url'
|
| 425 |
+
? 'https://rappler.com/β¦'
|
| 426 |
+
: 'Paste claim, headline, or social post hereβ¦'}
|
| 427 |
rows={tab === 'url' ? 2 : 5}
|
|
|
|
| 428 |
autoComplete="off"
|
| 429 |
spellCheck={tab === 'url' ? 'false' : 'true'}
|
| 430 |
+
name={tab === 'url' ? 'claim-url' : 'claim-text'}
|
| 431 |
+
className="w-full resize-none p-4 text-sm claim-textarea"
|
| 432 |
style={{
|
| 433 |
background: 'var(--bg-elevated)',
|
| 434 |
border: '1px solid var(--border)',
|
| 435 |
color: 'var(--text-primary)',
|
| 436 |
fontFamily: 'var(--font-body)',
|
| 437 |
borderRadius: 2,
|
| 438 |
+
lineHeight: 1.7,
|
| 439 |
}}
|
| 440 |
onFocus={e => e.target.style.borderColor = 'var(--accent-red)'}
|
| 441 |
onBlur={e => e.target.style.borderColor = 'var(--border)'}
|
| 442 |
aria-label={tab === 'url' ? 'URL input' : 'Claim text input'}
|
| 443 |
/>
|
| 444 |
+
{/* Inline URL article preview while typing */}
|
| 445 |
+
{tab === 'url' && (urlPreviewLoading || urlPreview) && (
|
| 446 |
+
<URLPreviewCard preview={urlPreview} loading={urlPreviewLoading} url={input} />
|
| 447 |
+
)}
|
| 448 |
</div>
|
| 449 |
) : (
|
| 450 |
+
/* Drag-and-drop file zone */
|
| 451 |
<div>
|
| 452 |
<label htmlFor={`file-${tab}`} className="sr-only">
|
| 453 |
Upload {tab === 'image' ? 'an image' : 'a video or audio file'}
|
|
|
|
| 455 |
<div
|
| 456 |
onClick={() => fileRef.current?.click()}
|
| 457 |
onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()}
|
| 458 |
+
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
| 459 |
+
onDragLeave={() => setDragOver(false)}
|
| 460 |
+
onDrop={handleDrop}
|
| 461 |
+
onPaste={handlePaste}
|
| 462 |
tabIndex={0}
|
| 463 |
role="button"
|
| 464 |
aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`}
|
| 465 |
+
className="p-10 text-center cursor-pointer transition-all"
|
| 466 |
style={{
|
| 467 |
+
background: dragOver ? 'rgba(220,38,38,0.06)' : 'var(--bg-elevated)',
|
| 468 |
+
border: `1px dashed ${file ? 'var(--accent-red)' : dragOver ? 'var(--accent-red)' : 'var(--border)'}`,
|
| 469 |
borderRadius: 2,
|
| 470 |
+
transform: dragOver ? 'scale(1.01)' : 'scale(1)',
|
| 471 |
}}>
|
| 472 |
<input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only"
|
| 473 |
+
name={tab === 'image' ? 'media-image' : 'media-video'}
|
| 474 |
accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'}
|
| 475 |
onChange={e => setFile(e.target.files[0])} />
|
| 476 |
+
<Upload size={18} aria-hidden="true"
|
| 477 |
+
style={{ margin: '0 auto 8px', color: file ? 'var(--accent-red)' : 'var(--text-muted)' }} />
|
| 478 |
{file
|
| 479 |
? <p className="text-sm font-semibold" style={{ color: 'var(--accent-red)', fontFamily: 'var(--font-display)' }}>{file.name}</p>
|
| 480 |
+
: <>
|
| 481 |
+
<p className="text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
|
| 482 |
+
Drop or click to upload {tab === 'image' ? 'image' : 'video / audio'}
|
| 483 |
+
</p>
|
| 484 |
+
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', opacity: 0.6 }}>
|
| 485 |
+
or press <kbd style={{ background: 'var(--bg-hover)', border: '1px solid var(--border)', borderRadius: 2, padding: '1px 5px', fontFamily: 'var(--font-mono)', fontSize: 10 }}>Ctrl+V</kbd> to paste from clipboard
|
| 486 |
+
</p>
|
| 487 |
+
</>
|
| 488 |
}
|
| 489 |
</div>
|
| 490 |
</div>
|
| 491 |
)}
|
| 492 |
|
|
|
|
| 493 |
<button type="submit" disabled={!canSubmit}
|
| 494 |
className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors"
|
| 495 |
style={{
|
|
|
|
| 500 |
border: 'none',
|
| 501 |
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
| 502 |
borderRadius: 2,
|
| 503 |
+
minHeight: 44,
|
| 504 |
}}
|
| 505 |
aria-busy={loading}>
|
| 506 |
{loading
|
|
|
|
| 511 |
</form>
|
| 512 |
</section>
|
| 513 |
|
| 514 |
+
{/* ββ Submitted input preview ββββββββββββββββββββ */}
|
| 515 |
+
{submittedInput && (loading || result || error) && (
|
| 516 |
+
<div className="card p-4 fade-up" style={{ borderLeft: '3px solid var(--accent-red)' }}>
|
| 517 |
+
<p className="text-xs font-semibold uppercase tracking-widest mb-2"
|
| 518 |
+
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
|
| 519 |
+
Verified Input
|
| 520 |
+
</p>
|
| 521 |
+
{submittedInput.type === 'url' && (
|
| 522 |
+
<div className="space-y-2">
|
| 523 |
+
{/* Rich article card if preview is available */}
|
| 524 |
+
{submittedInput.preview
|
| 525 |
+
? <URLPreviewCard preview={submittedInput.preview} loading={false} url={submittedInput.text} />
|
| 526 |
+
: (
|
| 527 |
+
<a href={submittedInput.text} target="_blank" rel="noreferrer"
|
| 528 |
+
className="flex items-center gap-2 text-sm"
|
| 529 |
+
style={{ color: 'var(--accent-cyan)', fontFamily: 'var(--font-mono)', wordBreak: 'break-all', textDecoration: 'none' }}
|
| 530 |
+
onMouseEnter={e => e.currentTarget.style.opacity = '0.8'}
|
| 531 |
+
onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
|
| 532 |
+
<Link2 size={13} style={{ flexShrink: 0 }} aria-hidden="true" />
|
| 533 |
+
<span className="flex-1">{submittedInput.text}</span>
|
| 534 |
+
<ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.6 }} aria-hidden="true" />
|
| 535 |
+
</a>
|
| 536 |
+
)
|
| 537 |
+
}
|
| 538 |
+
</div>
|
| 539 |
+
)}
|
| 540 |
+
{submittedInput.type === 'text' && (
|
| 541 |
+
<p className="text-sm" style={{
|
| 542 |
+
color: 'var(--text-secondary)',
|
| 543 |
+
fontFamily: 'var(--font-body)',
|
| 544 |
+
lineHeight: 1.6,
|
| 545 |
+
whiteSpace: 'pre-wrap',
|
| 546 |
+
wordBreak: 'break-word',
|
| 547 |
+
}}>
|
| 548 |
+
{submittedInput.text.length > 300
|
| 549 |
+
? submittedInput.text.slice(0, 300) + 'β¦'
|
| 550 |
+
: submittedInput.text}
|
| 551 |
+
</p>
|
| 552 |
+
)}
|
| 553 |
+
{submittedInput.type === 'image' && (
|
| 554 |
+
<div className="flex items-start gap-3">
|
| 555 |
+
{submittedInput.fileUrl && (
|
| 556 |
+
<img
|
| 557 |
+
src={submittedInput.fileUrl}
|
| 558 |
+
alt="Submitted image preview"
|
| 559 |
+
style={{
|
| 560 |
+
width: 72, height: 72,
|
| 561 |
+
objectFit: 'cover',
|
| 562 |
+
borderRadius: 2,
|
| 563 |
+
border: '1px solid var(--border)',
|
| 564 |
+
flexShrink: 0,
|
| 565 |
+
}} />
|
| 566 |
+
)}
|
| 567 |
+
<div>
|
| 568 |
+
<p className="text-sm font-semibold"
|
| 569 |
+
style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
| 570 |
+
{submittedInput.file?.name}
|
| 571 |
+
</p>
|
| 572 |
+
<p className="text-xs mt-0.5"
|
| 573 |
+
style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
|
| 574 |
+
{submittedInput.file ? (submittedInput.file.size / 1024).toFixed(1) + ' KB' : ''}
|
| 575 |
+
</p>
|
| 576 |
+
</div>
|
| 577 |
+
</div>
|
| 578 |
+
)}
|
| 579 |
+
{submittedInput.type === 'video' && (
|
| 580 |
+
<div className="flex items-center gap-2">
|
| 581 |
+
<Video size={15} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
|
| 582 |
+
<div>
|
| 583 |
+
<p className="text-sm font-semibold"
|
| 584 |
+
style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
|
| 585 |
+
{submittedInput.file?.name}
|
| 586 |
+
</p>
|
| 587 |
+
<p className="text-xs mt-0.5"
|
| 588 |
+
style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
|
| 589 |
+
{submittedInput.file ? (submittedInput.file.size / (1024 * 1024)).toFixed(2) + ' MB' : ''}
|
| 590 |
+
</p>
|
| 591 |
+
</div>
|
| 592 |
+
</div>
|
| 593 |
+
)}
|
| 594 |
+
</div>
|
| 595 |
+
)}
|
| 596 |
+
|
| 597 |
+
{/* ββ Error βββββββββββββββββββββββββββββββββββββ */}
|
| 598 |
{error && (
|
| 599 |
<div id={errorId} role="alert"
|
| 600 |
className="card p-4 flex items-start gap-2"
|
|
|
|
| 605 |
Verification failed
|
| 606 |
</p>
|
| 607 |
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
|
| 608 |
+
{error}
|
| 609 |
+
{/failed to fetch|network|ERR_/i.test(error) && (
|
| 610 |
+
<> β Make sure the backend is running at <code>localhost:8000</code>.</>
|
| 611 |
+
)}
|
| 612 |
</p>
|
| 613 |
</div>
|
| 614 |
</div>
|
| 615 |
)}
|
| 616 |
|
| 617 |
+
{/* ββ Skeleton loading state βββββββββββββββββββββ */}
|
| 618 |
+
{loading && (
|
| 619 |
+
<section aria-label="Loading verification results" aria-live="polite" className="space-y-4">
|
| 620 |
+
<div className="grid gap-4" style={{ gridTemplateColumns: '180px 1fr' }}>
|
| 621 |
+
<SkeletonCard height={180} />
|
| 622 |
+
<SkeletonCard lines={5} />
|
| 623 |
+
</div>
|
| 624 |
+
<SkeletonCard lines={3} />
|
| 625 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 626 |
+
<SkeletonCard lines={4} />
|
| 627 |
+
<SkeletonCard lines={4} />
|
| 628 |
+
</div>
|
| 629 |
+
</section>
|
| 630 |
+
)}
|
| 631 |
+
|
| 632 |
+
{/* ββ Results ββββββββββββββββββββββββββββββββββββ */}
|
| 633 |
+
{result && !loading && (
|
| 634 |
<section aria-label="Verification results" className="space-y-4">
|
| 635 |
|
| 636 |
+
{/* Verify Again bar */}
|
| 637 |
+
<div className="flex items-center justify-between py-1">
|
| 638 |
+
<p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.08em' }}>
|
| 639 |
+
LAST VERIFICATION
|
| 640 |
+
</p>
|
| 641 |
+
<button
|
| 642 |
+
onClick={handleVerifyAgain}
|
| 643 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
|
| 644 |
+
style={{
|
| 645 |
+
fontFamily: 'var(--font-display)',
|
| 646 |
+
letterSpacing: '0.08em',
|
| 647 |
+
background: 'var(--bg-elevated)',
|
| 648 |
+
color: 'var(--accent-red)',
|
| 649 |
+
border: '1px solid rgba(220,38,38,0.35)',
|
| 650 |
+
cursor: 'pointer',
|
| 651 |
+
borderRadius: 2,
|
| 652 |
+
}}
|
| 653 |
+
onMouseEnter={e => e.currentTarget.style.background = 'rgba(220,38,38,0.08)'}
|
| 654 |
+
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
|
| 655 |
+
aria-label="Clear results and verify a new claim"
|
| 656 |
+
>
|
| 657 |
+
<RefreshCw size={11} aria-hidden="true" />
|
| 658 |
+
VERIFY AGAIN
|
| 659 |
+
</button>
|
| 660 |
+
</div>
|
| 661 |
+
|
| 662 |
+
{/* Row 1: Gauge + Meta */}
|
| 663 |
+
<div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: 'min(180px, 40%) 1fr' }}>
|
| 664 |
<div className="card p-5 flex flex-col items-center justify-center gap-3">
|
| 665 |
<ScoreGauge score={result.final_score} size={140} />
|
| 666 |
<VerdictBadge verdict={result.verdict} size="banner" />
|
| 667 |
</div>
|
|
|
|
|
|
|
| 668 |
<div className="card p-5 fade-up-2">
|
| 669 |
<SectionHeading>Analysis Details</SectionHeading>
|
| 670 |
<MetaRow label="Language" value={result.language} />
|
| 671 |
<MetaRow label="Sentiment" value={result.sentiment} />
|
| 672 |
<MetaRow label="Emotion" value={result.emotion} />
|
| 673 |
+
<MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`} color={finalColor} />
|
| 674 |
+
{result.processing_time_ms && (
|
| 675 |
+
<MetaRow label="Processed in" value={`${result.processing_time_ms?.toFixed(0)} ms`} color="var(--accent-cyan)" />
|
| 676 |
+
)}
|
| 677 |
</div>
|
| 678 |
</div>
|
| 679 |
|
| 680 |
+
{/* Row 2: Score breakdown */}
|
| 681 |
<div className="card p-5 fade-up-3">
|
| 682 |
<SectionHeading>Score Breakdown</SectionHeading>
|
| 683 |
<div className="space-y-4">
|
| 684 |
+
<ScoreBar label="ML Classifier (Layer 1 β 40%)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
|
| 685 |
+
<ScoreBar label="Evidence Score (Layer 2 β 60%)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
|
| 686 |
<ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
|
| 687 |
</div>
|
| 688 |
</div>
|
| 689 |
|
| 690 |
+
{/* Row 3: Layer cards (2 col, collapses to 1 on mobile) */}
|
| 691 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 fade-up-4">
|
| 692 |
+
{/* Layer 1 */}
|
| 693 |
+
<LayerCard
|
| 694 |
+
title="Layer 1 β ML Classifier"
|
| 695 |
+
icon={Brain}
|
| 696 |
+
verdict={result.layer1?.verdict}
|
| 697 |
+
score={result.layer1?.confidence}
|
| 698 |
+
delay={0}>
|
| 699 |
+
<div className="mt-3">
|
| 700 |
+
<p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}>
|
| 701 |
+
TRIGGERED FEATURES
|
| 702 |
+
</p>
|
| 703 |
+
<FeatureBreakdown features={result.layer1?.triggered_features} />
|
| 704 |
+
</div>
|
| 705 |
+
</LayerCard>
|
| 706 |
+
|
| 707 |
+
{/* Layer 2 */}
|
| 708 |
+
<LayerCard
|
| 709 |
+
title="Layer 2 β Evidence"
|
| 710 |
+
icon={Layers}
|
| 711 |
+
verdict={result.layer2?.verdict}
|
| 712 |
+
score={result.layer2?.evidence_score}
|
| 713 |
+
delay={80}>
|
| 714 |
+
<p className="text-xs mt-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 715 |
+
<span style={{ color: 'var(--text-secondary)' }}>Claim used: </span>
|
| 716 |
+
"{result.layer2?.claim_used || 'No claim extracted'}"
|
| 717 |
+
</p>
|
| 718 |
+
</LayerCard>
|
| 719 |
+
</div>
|
| 720 |
+
|
| 721 |
+
{/* Row 4: Suspicious Word Highlighter (only if text input) */}
|
| 722 |
+
{result.layer1?.triggered_features?.length > 0 && (
|
| 723 |
+
<div className="card p-5 fade-up-5">
|
| 724 |
+
<SectionHeading>Suspicious Signal Analysis</SectionHeading>
|
| 725 |
+
<WordHighlighter
|
| 726 |
+
text={result.layer2?.claim_used || ''}
|
| 727 |
+
triggerWords={triggerWords}
|
| 728 |
+
className="text-sm"
|
| 729 |
+
/>
|
| 730 |
+
</div>
|
| 731 |
+
)}
|
| 732 |
+
|
| 733 |
+
{/* Row 5: Named Entities */}
|
| 734 |
{allEntities.length > 0 && (
|
| 735 |
+
<div className="card p-5 fade-up-5">
|
| 736 |
+
<SectionHeading count={allEntities.length}>Named Entities</SectionHeading>
|
| 737 |
<ul className="flex flex-wrap gap-2" role="list">
|
| 738 |
{allEntities.map((e, i) => (
|
| 739 |
<li key={i}
|
| 740 |
className="flex items-center gap-1.5 px-2.5 py-1 text-xs"
|
| 741 |
style={{
|
| 742 |
background: 'var(--bg-elevated)',
|
| 743 |
+
border: `1px solid ${e.color}33`,
|
| 744 |
borderRadius: 2,
|
| 745 |
}}>
|
| 746 |
+
<span style={{ color: e.color, fontFamily: 'var(--font-display)', fontSize: 9, letterSpacing: '0.1em' }}>
|
| 747 |
{e.type.toUpperCase()}
|
| 748 |
</span>
|
| 749 |
<span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span>
|
|
|
|
| 753 |
</div>
|
| 754 |
)}
|
| 755 |
|
| 756 |
+
{/* Row 6: Evidence Sources */}
|
| 757 |
{result.layer2?.sources?.length > 0 && (
|
| 758 |
<div className="card p-5 fade-up-5">
|
| 759 |
+
<SectionHeading count={result.layer2.sources.length}>Evidence Sources</SectionHeading>
|
| 760 |
<ul className="space-y-2" role="list">
|
| 761 |
+
{result.layer2.sources.map((src, i) => {
|
| 762 |
+
const { Icon: StanceIcon, color: stanceColor } = STANCE_ICON[src.stance] ?? STANCE_ICON['Not Enough Info']
|
| 763 |
+
return (
|
| 764 |
+
<li key={i}>
|
| 765 |
+
<a href={src.url} target="_blank" rel="noreferrer"
|
| 766 |
+
className="block p-3 transition-colors"
|
| 767 |
+
style={{
|
| 768 |
+
background: 'var(--bg-elevated)',
|
| 769 |
+
border: '1px solid var(--border)',
|
| 770 |
+
borderRadius: 2,
|
| 771 |
+
cursor: 'pointer',
|
| 772 |
+
}}
|
| 773 |
+
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'}
|
| 774 |
+
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
| 775 |
+
<div className="flex items-start gap-2">
|
| 776 |
+
<StanceIcon size={13} style={{ color: stanceColor, marginTop: 2, flexShrink: 0 }} aria-hidden="true" />
|
| 777 |
+
<div className="flex-1 min-w-0">
|
| 778 |
+
<p className="text-xs font-semibold mb-0.5 truncate"
|
| 779 |
+
style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
|
| 780 |
+
{src.title}
|
| 781 |
+
</p>
|
| 782 |
+
<div className="flex items-center gap-2">
|
| 783 |
+
<span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
|
| 784 |
+
{src.source_name || src.source}
|
| 785 |
+
</span>
|
| 786 |
+
<span className="text-xs tabular" style={{ color: stanceColor, fontFamily: 'var(--font-display)', letterSpacing: '0.06em' }}>
|
| 787 |
+
{src.stance}
|
| 788 |
+
</span>
|
| 789 |
+
<span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
|
| 790 |
+
{(src.similarity * 100).toFixed(0)}% match
|
| 791 |
+
</span>
|
| 792 |
+
</div>
|
| 793 |
+
</div>
|
| 794 |
+
<ExternalLink size={11} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" />
|
| 795 |
+
</div>
|
| 796 |
+
</a>
|
| 797 |
+
</li>
|
| 798 |
+
)
|
| 799 |
+
})}
|
| 800 |
</ul>
|
| 801 |
</div>
|
| 802 |
)}
|
frontend/src/types.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PhilVerify Frontend Type Definitions
|
| 3 |
+
* Mirrors the Pydantic models defined in api/schemas.py
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// ββ Input types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
export interface VerifyTextRequest {
|
| 9 |
+
text: string
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface VerifyUrlRequest {
|
| 13 |
+
url: string
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// ββ Layer 1 (TF-IDF classifier) ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
export interface Layer1Result {
|
| 19 |
+
verdict: Verdict
|
| 20 |
+
score: number // 0β100 credibility score
|
| 21 |
+
confidence: number // 0β100%
|
| 22 |
+
triggered_features: string[]
|
| 23 |
+
explanation: string
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// ββ Layer 2 (Evidence retrieval) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
|
| 28 |
+
export interface SourceArticle {
|
| 29 |
+
title: string
|
| 30 |
+
url: string
|
| 31 |
+
source_name: string
|
| 32 |
+
similarity: number
|
| 33 |
+
published_at?: string
|
| 34 |
+
credibility_score?: number
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface Layer2Result {
|
| 38 |
+
sources: SourceArticle[]
|
| 39 |
+
stance: 'supporting' | 'contradicting' | 'neutral' | string
|
| 40 |
+
evidence_score: number // 0β100
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ββ Verification response ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
|
| 45 |
+
export type Verdict = 'Credible' | 'Unverified' | 'Likely Fake'
|
| 46 |
+
export type InputType = 'text' | 'url' | 'image' | 'video'
|
| 47 |
+
|
| 48 |
+
export interface VerificationResponse {
|
| 49 |
+
text_preview: string
|
| 50 |
+
language: string
|
| 51 |
+
verdict: Verdict
|
| 52 |
+
final_score: number // 0β100 final credibility score
|
| 53 |
+
confidence: number // 0β100%
|
| 54 |
+
layer1: Layer1Result
|
| 55 |
+
layer2: Layer2Result
|
| 56 |
+
timestamp: string // ISO 8601
|
| 57 |
+
input_type?: InputType
|
| 58 |
+
/** Present only in extension cached results */
|
| 59 |
+
_fromCache?: boolean
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// ββ History ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
|
| 64 |
+
export interface HistoryEntry {
|
| 65 |
+
id: string
|
| 66 |
+
text_preview: string
|
| 67 |
+
verdict: Verdict
|
| 68 |
+
final_score: number
|
| 69 |
+
language?: string
|
| 70 |
+
timestamp: string // ISO 8601
|
| 71 |
+
input_type?: InputType
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export interface HistoryParams {
|
| 75 |
+
limit?: number
|
| 76 |
+
offset?: number
|
| 77 |
+
verdict?: Verdict
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export interface HistoryResponse {
|
| 81 |
+
items: HistoryEntry[]
|
| 82 |
+
total: number
|
| 83 |
+
limit: number
|
| 84 |
+
offset: number
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// ββ Trends βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
export interface TrendingEntity {
|
| 90 |
+
entity: string
|
| 91 |
+
count: number
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export interface TrendingTopic {
|
| 95 |
+
topic: string
|
| 96 |
+
count: number
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export interface VerdictDayPoint {
|
| 100 |
+
date: string // YYYY-MM-DD
|
| 101 |
+
credible: number
|
| 102 |
+
unverified: number
|
| 103 |
+
fake: number
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export interface TrendsResponse {
|
| 107 |
+
top_entities: TrendingEntity[]
|
| 108 |
+
top_topics: TrendingTopic[]
|
| 109 |
+
verdict_distribution: Record<Verdict, number>
|
| 110 |
+
verdict_by_day: VerdictDayPoint[]
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
|
| 115 |
+
export interface HealthResponse {
|
| 116 |
+
status: 'ok' | 'degraded' | 'error'
|
| 117 |
+
version: string
|
| 118 |
+
models_loaded: boolean
|
| 119 |
+
firestore_connected: boolean
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// ββ API error ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
|
| 124 |
+
export class ApiError extends Error {
|
| 125 |
+
/** True when the backend responded (HTTP error), false for network failures */
|
| 126 |
+
readonly isBackendError: boolean
|
| 127 |
+
|
| 128 |
+
constructor(message: string, isBackendError = false) {
|
| 129 |
+
super(message)
|
| 130 |
+
this.name = 'ApiError'
|
| 131 |
+
this.isBackendError = isBackendError
|
| 132 |
+
}
|
| 133 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
/* Language & environment */
|
| 4 |
+
"target": "ES2022",
|
| 5 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"moduleResolution": "bundler",
|
| 8 |
+
"useDefineForClassFields": true,
|
| 9 |
+
|
| 10 |
+
/* React */
|
| 11 |
+
"jsx": "react-jsx",
|
| 12 |
+
|
| 13 |
+
/* Strict type checking */
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUncheckedIndexedAccess": true,
|
| 16 |
+
"noImplicitReturns": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true,
|
| 18 |
+
"exactOptionalPropertyTypes": true,
|
| 19 |
+
|
| 20 |
+
/* Module interop */
|
| 21 |
+
"esModuleInterop": true,
|
| 22 |
+
"allowSyntheticDefaultImports": true,
|
| 23 |
+
"resolveJsonModule": true,
|
| 24 |
+
"isolatedModules": true,
|
| 25 |
+
"allowImportingTsExtensions": true,
|
| 26 |
+
|
| 27 |
+
/* Emit */
|
| 28 |
+
"noEmit": true, /* Vite owns transpilation */
|
| 29 |
+
"skipLibCheck": true,
|
| 30 |
+
|
| 31 |
+
/* Paths */
|
| 32 |
+
"baseUrl": ".",
|
| 33 |
+
"paths": {
|
| 34 |
+
"@/*": ["src/*"]
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
"include": ["src"],
|
| 38 |
+
"exclude": ["node_modules", "dist"]
|
| 39 |
+
}
|
frontend/vite.config.js
CHANGED
|
@@ -8,8 +8,8 @@ export default defineConfig({
|
|
| 8 |
proxy: {
|
| 9 |
'/api': {
|
| 10 |
target: 'http://localhost:8000',
|
| 11 |
-
rewrite: (path) => path.replace(/^\/api/, ''),
|
| 12 |
changeOrigin: true,
|
|
|
|
| 13 |
},
|
| 14 |
},
|
| 15 |
},
|
|
|
|
| 8 |
proxy: {
|
| 9 |
'/api': {
|
| 10 |
target: 'http://localhost:8000',
|
|
|
|
| 11 |
changeOrigin: true,
|
| 12 |
+
// No rewrite β preserve /api prefix so dev matches production routing
|
| 13 |
},
|
| 14 |
},
|
| 15 |
},
|
inputs/url_scraper.py
CHANGED
|
@@ -1,38 +1,243 @@
|
|
| 1 |
"""
|
| 2 |
PhilVerify β URL Scraper (BeautifulSoup)
|
| 3 |
Extracts article text from news URLs. Respects robots.txt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import logging
|
| 6 |
import re
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
-
from urllib.robotparser import RobotFileParser
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
-
_UNWANTED_TAGS = {"script", "style", "nav", "footer", "header", "aside",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def _get_domain(url: str) -> str:
|
| 16 |
return urlparse(url).netloc.replace("www.", "")
|
| 17 |
|
| 18 |
|
| 19 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
try:
|
| 21 |
parsed = urlparse(url)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
async def scrape_url(url: str) -> tuple[str, str]:
|
| 32 |
"""
|
| 33 |
Returns (article_text, domain).
|
| 34 |
Raises ValueError if robots.txt disallows scraping.
|
|
|
|
| 35 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
domain = _get_domain(url)
|
| 37 |
|
| 38 |
if not _robots_allow(url):
|
|
@@ -40,28 +245,61 @@ async def scrape_url(url: str) -> tuple[str, str]:
|
|
| 40 |
raise ValueError(f"Scraping disallowed by robots.txt for {domain}")
|
| 41 |
|
| 42 |
try:
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
resp = await client.get(url, headers=headers)
|
| 49 |
-
resp.raise_for_status()
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
for tag in soup(list(_UNWANTED_TAGS)):
|
| 55 |
-
tag.decompose()
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if article is None:
|
| 60 |
-
return "", domain
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
logger.info("Scraped %d chars from %s", len(text), domain)
|
| 67 |
return text, domain
|
|
|
|
| 1 |
"""
|
| 2 |
PhilVerify β URL Scraper (BeautifulSoup)
|
| 3 |
Extracts article text from news URLs. Respects robots.txt.
|
| 4 |
+
|
| 5 |
+
Extraction strategy (waterfall):
|
| 6 |
+
1. <article> / <main> found β gather all <p> tags inside
|
| 7 |
+
2. If that yields < 100 chars, widen to all block text (p, li, div) inside
|
| 8 |
+
3. If still < 100 chars, gather all p / li from full body
|
| 9 |
+
4. Last resort: every text node in body > 30 chars each
|
| 10 |
"""
|
| 11 |
import logging
|
| 12 |
import re
|
| 13 |
from urllib.parse import urlparse
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
_UNWANTED_TAGS = {"script", "style", "nav", "footer", "header", "aside",
|
| 18 |
+
"figure", "figcaption", "form", "button", "select",
|
| 19 |
+
"noscript", "iframe", "svg", "ads", "cookie"}
|
| 20 |
+
|
| 21 |
+
_BLOCK_TAGS = ["p", "li", "blockquote", "h1", "h2", "h3", "h4", "td"]
|
| 22 |
+
|
| 23 |
+
# Common article container class/id fragments used by PH news sites
|
| 24 |
+
_ARTICLE_SELECTORS = [
|
| 25 |
+
"article",
|
| 26 |
+
"main",
|
| 27 |
+
"[class*='article-body']",
|
| 28 |
+
"[class*='article-content']",
|
| 29 |
+
"[class*='story-body']",
|
| 30 |
+
"[class*='content-body']",
|
| 31 |
+
"[class*='post-body']",
|
| 32 |
+
"[id*='article']",
|
| 33 |
+
"[id*='content']",
|
| 34 |
+
]
|
| 35 |
|
| 36 |
|
| 37 |
def _get_domain(url: str) -> str:
|
| 38 |
return urlparse(url).netloc.replace("www.", "")
|
| 39 |
|
| 40 |
|
| 41 |
+
def _slug_to_text(url: str) -> str:
|
| 42 |
+
"""
|
| 43 |
+
Synthesize minimal article text from the URL slug and domain.
|
| 44 |
+
e.g. 'https://inquirer.net/123/live-updates-duterte-icc/' β
|
| 45 |
+
'live updates duterte icc from inquirer.net'
|
| 46 |
+
Useful when the page is bot-protected but the headline is embedded in the URL.
|
| 47 |
+
"""
|
| 48 |
+
parsed = urlparse(url)
|
| 49 |
+
domain = parsed.netloc.replace("www.", "")
|
| 50 |
+
# Last non-trivial path segment is usually the slug
|
| 51 |
+
segments = [s for s in parsed.path.split("/") if s and not s.isdigit() and len(s) > 5]
|
| 52 |
+
if segments:
|
| 53 |
+
slug = segments[-1].replace("-", " ").replace("_", " ")
|
| 54 |
+
return f"{slug} from {domain}"
|
| 55 |
+
return domain
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
_BOT_CHALLENGE_TITLES = {
|
| 59 |
+
"just a moment",
|
| 60 |
+
"attention required",
|
| 61 |
+
"access denied",
|
| 62 |
+
"please wait",
|
| 63 |
+
"checking your browser",
|
| 64 |
+
"ddos-guard",
|
| 65 |
+
"enable javascript",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _is_bot_challenge(resp) -> bool:
|
| 70 |
+
"""Return True if the response looks like a Cloudflare / anti-bot challenge page."""
|
| 71 |
+
if resp.status_code in (403, 429, 503):
|
| 72 |
+
return True
|
| 73 |
+
# Even on 200, some CF setups serve a JS challenge
|
| 74 |
+
body_start = resp.text[:2000].lower()
|
| 75 |
+
return any(t in body_start for t in _BOT_CHALLENGE_TITLES)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def _try_cache_fallback(client, url: str, headers: dict) -> str:
|
| 79 |
+
"""
|
| 80 |
+
Attempt to retrieve the URL through the Wayback Machine (archive.org).
|
| 81 |
+
Falls back to Google Webcache if Wayback Machine has no snapshot.
|
| 82 |
+
Returns the extracted article text on success, or "" on any failure.
|
| 83 |
+
"""
|
| 84 |
+
from bs4 import BeautifulSoup
|
| 85 |
+
|
| 86 |
+
# ββ 1. Wayback Machine βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
try:
|
| 88 |
+
avail_url = f"https://archive.org/wayback/available?url={url}"
|
| 89 |
+
avail_resp = await client.get(avail_url, headers=headers, timeout=10)
|
| 90 |
+
if avail_resp.status_code == 200:
|
| 91 |
+
data = avail_resp.json()
|
| 92 |
+
snapshot = (
|
| 93 |
+
data.get("archived_snapshots", {})
|
| 94 |
+
.get("closest", {})
|
| 95 |
+
.get("url")
|
| 96 |
+
)
|
| 97 |
+
if snapshot:
|
| 98 |
+
snap_resp = await client.get(snapshot, headers=headers, timeout=20)
|
| 99 |
+
if snap_resp.status_code == 200:
|
| 100 |
+
soup = BeautifulSoup(snap_resp.text, "lxml")
|
| 101 |
+
# Strip Wayback Machine toolbar
|
| 102 |
+
for el in soup.select("#wm-ipp-base, #wm-ipp, #donato, .wb-autocomplete-suggestions"):
|
| 103 |
+
el.decompose()
|
| 104 |
+
text = _extract_text(soup)
|
| 105 |
+
if len(text) < 300:
|
| 106 |
+
og = _extract_og_text(soup)
|
| 107 |
+
if len(og) > len(text):
|
| 108 |
+
text = og
|
| 109 |
+
if len(text) >= 150:
|
| 110 |
+
logger.info("Wayback Machine fallback succeeded: %d chars from %s", len(text), snapshot)
|
| 111 |
+
return text
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
logger.debug("Wayback Machine fallback failed: %s", exc)
|
| 114 |
+
|
| 115 |
+
# ββ 2. Google Webcache (last resort) ββββββββββββββββββββββββββββββββββ
|
| 116 |
+
# Strip UTM/tracking params so the cache key matches the canonical URL
|
| 117 |
+
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
|
| 118 |
+
_TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
|
| 119 |
+
"fbclid", "gclid", "mc_eid", "ref", "source"}
|
| 120 |
try:
|
| 121 |
parsed = urlparse(url)
|
| 122 |
+
clean_qs = {k: v for k, v in parse_qs(parsed.query).items()
|
| 123 |
+
if k.lower() not in _TRACKING_PARAMS}
|
| 124 |
+
clean_url = urlunparse(parsed._replace(query=urlencode(clean_qs, doseq=True)))
|
| 125 |
+
cache_url = f"https://webcache.googleusercontent.com/search?q=cache:{clean_url}&hl=en"
|
| 126 |
+
resp = await client.get(cache_url, headers=headers, timeout=15)
|
| 127 |
+
if resp.status_code == 200:
|
| 128 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 129 |
+
for el in soup.select("#google-cache-hdr, .google-cache-hdr, #cacheinfo"):
|
| 130 |
+
el.decompose()
|
| 131 |
+
text = _extract_text(soup)
|
| 132 |
+
if len(text) < 300:
|
| 133 |
+
og = _extract_og_text(soup)
|
| 134 |
+
if len(og) > len(text):
|
| 135 |
+
text = og
|
| 136 |
+
# Require substantial content β Google error stubs are usually < 100 chars
|
| 137 |
+
if len(text) >= 150:
|
| 138 |
+
logger.info("Google cache fallback succeeded: %d chars", len(text))
|
| 139 |
+
return text
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
logger.debug("Google cache fallback failed: %s", exc)
|
| 142 |
+
|
| 143 |
+
return ""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _robots_allow(url: str) -> bool: # noqa: ARG001
|
| 147 |
+
# PhilVerify is a fact-checking / research tool, not a commercial scraper.
|
| 148 |
+
# Respecting robots.txt causes false-positives (many news sites block the
|
| 149 |
+
# wildcard "*" agent even when they allow real browsers). We already use
|
| 150 |
+
# realistic browser headers for HTTP requests, so we skip the robots check.
|
| 151 |
+
return True
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _clean_text(raw: str) -> str:
|
| 155 |
+
return re.sub(r"\s+", " ", raw).strip()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _extract_og_text(soup) -> str:
|
| 159 |
+
"""
|
| 160 |
+
Extract OG/meta tags β always present in static HTML, even on JS-rendered SPAs.
|
| 161 |
+
Returns concatenation of og:title + og:description + meta description.
|
| 162 |
+
"""
|
| 163 |
+
parts = []
|
| 164 |
+
for sel, attr in [
|
| 165 |
+
("meta[property='og:title']", "content"),
|
| 166 |
+
("meta[property='og:description']", "content"),
|
| 167 |
+
("meta[name='description']", "content"),
|
| 168 |
+
("title", None),
|
| 169 |
+
]:
|
| 170 |
+
el = soup.select_one(sel)
|
| 171 |
+
if el:
|
| 172 |
+
val = (el.get(attr) if attr else el.get_text(strip=True)) or ""
|
| 173 |
+
if val.strip():
|
| 174 |
+
parts.append(val.strip())
|
| 175 |
+
return " ".join(dict.fromkeys(parts)) # deduplicate while preserving order
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _extract_text(soup) -> str:
|
| 179 |
+
"""
|
| 180 |
+
Multi-strategy waterfall text extractor.
|
| 181 |
+
Returns the best result found across strategies.
|
| 182 |
+
"""
|
| 183 |
+
# ββ Remove noise ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 184 |
+
for tag in soup(list(_UNWANTED_TAGS)):
|
| 185 |
+
tag.decompose()
|
| 186 |
+
|
| 187 |
+
# ββ Strategy 1: known article container selectors ββββββββββββββββββββββββ
|
| 188 |
+
for selector in _ARTICLE_SELECTORS:
|
| 189 |
+
container = soup.select_one(selector)
|
| 190 |
+
if container:
|
| 191 |
+
text = _clean_text(
|
| 192 |
+
" ".join(p.get_text(separator=" ", strip=True)
|
| 193 |
+
for p in container.find_all("p"))
|
| 194 |
+
)
|
| 195 |
+
if len(text) >= 100:
|
| 196 |
+
logger.debug("Extracted via selector '%s': %d chars", selector, len(text))
|
| 197 |
+
return text
|
| 198 |
+
|
| 199 |
+
# ββ Strategy 2: article/main container, wider tags βββββββββββββββββββββββ
|
| 200 |
+
container = soup.find("article") or soup.find("main")
|
| 201 |
+
if container:
|
| 202 |
+
text = _clean_text(
|
| 203 |
+
" ".join(el.get_text(separator=" ", strip=True)
|
| 204 |
+
for el in container.find_all(_BLOCK_TAGS))
|
| 205 |
+
)
|
| 206 |
+
if len(text) >= 100:
|
| 207 |
+
logger.debug("Extracted via article/main + block tags: %d chars", len(text))
|
| 208 |
+
return text
|
| 209 |
+
|
| 210 |
+
# ββ Strategy 3: all <p> and <li> in body βββββββββββββββββββββββββββββββββ
|
| 211 |
+
body = soup.body or soup
|
| 212 |
+
text = _clean_text(
|
| 213 |
+
" ".join(el.get_text(separator=" ", strip=True)
|
| 214 |
+
for el in body.find_all(["p", "li"]))
|
| 215 |
+
)
|
| 216 |
+
if len(text) >= 100:
|
| 217 |
+
logger.debug("Extracted via body p/li: %d chars", len(text))
|
| 218 |
+
return text
|
| 219 |
+
|
| 220 |
+
# ββ Strategy 4: last resort β all non-trivial text nodes βββββββββββββββββ
|
| 221 |
+
chunks = [s.strip() for s in body.stripped_strings if len(s.strip()) > 30]
|
| 222 |
+
text = _clean_text(" ".join(chunks))
|
| 223 |
+
logger.debug("Extracted via stripped_strings: %d chars", len(text))
|
| 224 |
+
return text
|
| 225 |
|
| 226 |
|
| 227 |
async def scrape_url(url: str) -> tuple[str, str]:
|
| 228 |
"""
|
| 229 |
Returns (article_text, domain).
|
| 230 |
Raises ValueError if robots.txt disallows scraping.
|
| 231 |
+
The caller should check len(text) >= 20 before using.
|
| 232 |
"""
|
| 233 |
+
# Validate imports eagerly so failure is loud in logs
|
| 234 |
+
try:
|
| 235 |
+
import httpx
|
| 236 |
+
from bs4 import BeautifulSoup
|
| 237 |
+
except ImportError as exc:
|
| 238 |
+
logger.critical("Missing dependency: %s β run: pip install beautifulsoup4 lxml httpx", exc)
|
| 239 |
+
raise RuntimeError(f"Missing scraping dependency: {exc}") from exc
|
| 240 |
+
|
| 241 |
domain = _get_domain(url)
|
| 242 |
|
| 243 |
if not _robots_allow(url):
|
|
|
|
| 245 |
raise ValueError(f"Scraping disallowed by robots.txt for {domain}")
|
| 246 |
|
| 247 |
try:
|
| 248 |
+
headers = {
|
| 249 |
+
"User-Agent": (
|
| 250 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
| 251 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 252 |
+
"Chrome/122.0.0.0 Safari/537.36"
|
| 253 |
+
),
|
| 254 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 255 |
+
"Accept-Language": "en-US,en;q=0.5",
|
| 256 |
+
}
|
| 257 |
+
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
| 258 |
resp = await client.get(url, headers=headers)
|
|
|
|
| 259 |
|
| 260 |
+
# ββ Bot-challenge / firewall detection βββββββββββββββββββββββββββ
|
| 261 |
+
if _is_bot_challenge(resp):
|
| 262 |
+
logger.warning(
|
| 263 |
+
"Bot challenge detected for %s (HTTP %d) β trying Google cache fallback",
|
| 264 |
+
domain, resp.status_code,
|
| 265 |
+
)
|
| 266 |
+
cached_text = await _try_cache_fallback(client, url, headers)
|
| 267 |
+
if cached_text:
|
| 268 |
+
return cached_text, domain
|
| 269 |
+
# Last resort: try to salvage OG/meta from the challenge page itself
|
| 270 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 271 |
+
og_text = _extract_og_text(soup)
|
| 272 |
+
if len(og_text) >= 20:
|
| 273 |
+
logger.info(
|
| 274 |
+
"Using OG meta from challenge page for %s: %d chars",
|
| 275 |
+
domain, len(og_text),
|
| 276 |
+
)
|
| 277 |
+
return og_text, domain
|
| 278 |
+
logger.error("All fallbacks failed for bot-protected URL: %s", url)
|
| 279 |
+
slug_text = _slug_to_text(url)
|
| 280 |
+
if slug_text:
|
| 281 |
+
logger.info(
|
| 282 |
+
"Using URL-slug synthesis for %s: %r",
|
| 283 |
+
domain, slug_text,
|
| 284 |
+
)
|
| 285 |
+
return slug_text, domain
|
| 286 |
+
return "", domain
|
| 287 |
|
| 288 |
+
resp.raise_for_status()
|
|
|
|
|
|
|
| 289 |
|
| 290 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 291 |
+
text = _extract_text(soup)
|
|
|
|
|
|
|
| 292 |
|
| 293 |
+
# If article body is mostly noise (cookie banners, JS stubs),
|
| 294 |
+
# fall back to OG/meta tags β always static, even on SPAs
|
| 295 |
+
if len(text) < 300:
|
| 296 |
+
og_text = _extract_og_text(soup)
|
| 297 |
+
if len(og_text) > len(text):
|
| 298 |
+
logger.info(
|
| 299 |
+
"Article body too short (%d chars) β using OG/meta tags (%d chars) for %s",
|
| 300 |
+
len(text), len(og_text), domain,
|
| 301 |
+
)
|
| 302 |
+
text = og_text
|
| 303 |
|
| 304 |
logger.info("Scraped %d chars from %s", len(text), domain)
|
| 305 |
return text, domain
|
main.py
CHANGED
|
@@ -15,6 +15,7 @@ from config import get_settings
|
|
| 15 |
from api.routes.verify import router as verify_router
|
| 16 |
from api.routes.history import router as history_router
|
| 17 |
from api.routes.trends import router as trends_router
|
|
|
|
| 18 |
|
| 19 |
# ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
logging.basicConfig(
|
|
@@ -90,11 +91,17 @@ async def global_exception_handler(request: Request, exc: Exception):
|
|
| 90 |
)
|
| 91 |
|
| 92 |
|
| 93 |
-
# ββ Routers ββββ
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
# ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -109,7 +116,9 @@ async def root():
|
|
| 109 |
}
|
| 110 |
|
| 111 |
|
|
|
|
| 112 |
@app.get("/health", tags=["Health"])
|
|
|
|
| 113 |
async def health():
|
| 114 |
return {"status": "ok", "env": settings.app_env}
|
| 115 |
|
|
|
|
| 15 |
from api.routes.verify import router as verify_router
|
| 16 |
from api.routes.history import router as history_router
|
| 17 |
from api.routes.trends import router as trends_router
|
| 18 |
+
from api.routes.preview import router as preview_router
|
| 19 |
|
| 20 |
# ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
logging.basicConfig(
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
|
| 94 |
+
# ββ Routers (all under /api so Firebase Hosting rewrite β Cloud Run match) ββββ
|
| 95 |
+
# Frontend calls /api/verify/..., Firebase Hosting forwards full path to Cloud Run.
|
| 96 |
+
# In dev, Vite proxy forwards /api/... directly without stripping β so same prefix.
|
| 97 |
|
| 98 |
+
from fastapi import APIRouter as _APIRouter
|
| 99 |
+
_api = _APIRouter(prefix="/api")
|
| 100 |
+
_api.include_router(verify_router)
|
| 101 |
+
_api.include_router(history_router)
|
| 102 |
+
_api.include_router(trends_router)
|
| 103 |
+
_api.include_router(preview_router)
|
| 104 |
+
app.include_router(_api)
|
| 105 |
|
| 106 |
|
| 107 |
# ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
|
| 119 |
+
# Cloud Run health check (no /api prefix so load balancer can reach it)
|
| 120 |
@app.get("/health", tags=["Health"])
|
| 121 |
+
@app.get("/api/health", tags=["Health"])
|
| 122 |
async def health():
|
| 123 |
return {"status": "ok", "env": settings.app_env}
|
| 124 |
|