Spaces:
Running
Running
Commit ·
a96145c
1
Parent(s): 3f4dc00
Deploy NewsApp
Browse files- Dockerfile +18 -0
- config.py +100 -0
- main.py +133 -0
- refresh_task.py +120 -0
- requirements.txt +22 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/__pycache__/aggregator.cpython-312.pyc +0 -0
- src/__pycache__/analyzer.cpython-312.pyc +0 -0
- src/__pycache__/extractor.cpython-312.pyc +0 -0
- src/__pycache__/hn_scraper.cpython-312.pyc +0 -0
- src/__pycache__/mockdata.cpython-312.pyc +0 -0
- src/__pycache__/models.cpython-312.pyc +0 -0
- src/__pycache__/presenter.cpython-312.pyc +0 -0
- src/__pycache__/rss_feed_scraper.cpython-312.pyc +0 -0
- src/__pycache__/rss_scraper.cpython-312.pyc +0 -0
- src/__pycache__/scraper.cpython-312.pyc +0 -0
- src/aggregator.py +156 -0
- src/analyzer.py +410 -0
- src/extractor.py +92 -0
- src/hn_scraper.py +48 -0
- src/mockdata.py +189 -0
- src/models.py +56 -0
- src/presenter.py +48 -0
- src/rss_feed_scraper.py +164 -0
- src/scraper.py +65 -0
- src/src/__init__.py +0 -0
- src/src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/src/__pycache__/aggregator.cpython-312.pyc +0 -0
- src/src/__pycache__/analyzer.cpython-312.pyc +0 -0
- src/src/__pycache__/extractor.cpython-312.pyc +0 -0
- src/src/__pycache__/hn_scraper.cpython-312.pyc +0 -0
- src/src/__pycache__/mockdata.cpython-312.pyc +0 -0
- src/src/__pycache__/models.cpython-312.pyc +0 -0
- src/src/__pycache__/presenter.cpython-312.pyc +0 -0
- src/src/__pycache__/rss_feed_scraper.cpython-312.pyc +0 -0
- src/src/__pycache__/rss_scraper.cpython-312.pyc +0 -0
- src/src/__pycache__/scraper.cpython-312.pyc +0 -0
- src/src/aggregator.py +156 -0
- src/src/analyzer.py +410 -0
- src/src/extractor.py +92 -0
- src/src/hn_scraper.py +48 -0
- src/src/mockdata.py +189 -0
- src/src/models.py +56 -0
- src/src/presenter.py +48 -0
- src/src/rss_feed_scraper.py +164 -0
- src/src/scraper.py +65 -0
- templates/index.html +289 -0
- templates/templates/index.html +289 -0
- webapp.py +282 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
|
| 6 |
+
FROM python:3.12-slim
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
RUN addgroup --system app && adduser --system --group app
|
| 9 |
+
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
| 10 |
+
COPY . .
|
| 11 |
+
USER app
|
| 12 |
+
EXPOSE 7860
|
| 13 |
+
ENV FLASK_ENV=production \
|
| 14 |
+
PORT=7860 \
|
| 15 |
+
HOST=0.0.0.0 \
|
| 16 |
+
LOG_LEVEL=INFO \
|
| 17 |
+
PYTHONUNBUFFERED=1
|
| 18 |
+
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:7860", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "webapp:app"]
|
config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class Config:
|
| 10 |
+
reddit_client_id: str = os.getenv("REDDIT_CLIENT_ID", "")
|
| 11 |
+
reddit_client_secret: str = os.getenv("REDDIT_CLIENT_SECRET", "")
|
| 12 |
+
reddit_user_agent: str = os.getenv("REDDIT_USER_AGENT", "newsapp/1.0")
|
| 13 |
+
|
| 14 |
+
rss_feeds: list[str] = field(default_factory=lambda: [
|
| 15 |
+
# ---- Geopolitical ----
|
| 16 |
+
"https://feeds.bbci.co.uk/news/world/rss.xml",
|
| 17 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
| 18 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml",
|
| 19 |
+
"https://feeds.npr.org/1001/rss.xml",
|
| 20 |
+
"https://www.aljazeera.com/xml/rss/all.xml",
|
| 21 |
+
"https://www.theguardian.com/world/rss",
|
| 22 |
+
# ---- World Health ----
|
| 23 |
+
"https://feeds.bbci.co.uk/news/health/rss.xml",
|
| 24 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Science.xml",
|
| 25 |
+
"https://www.statnews.com/feed/",
|
| 26 |
+
"https://www.sciencedaily.com/rss/all.xml",
|
| 27 |
+
# ---- Tech ----
|
| 28 |
+
"https://feeds.bbci.co.uk/news/technology/rss.xml",
|
| 29 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Technology.xml",
|
| 30 |
+
"https://techcrunch.com/feed/",
|
| 31 |
+
"https://www.wired.com/feed/rss",
|
| 32 |
+
"https://www.theverge.com/rss/index.xml",
|
| 33 |
+
"https://arstechnica.com/feed/",
|
| 34 |
+
# ---- Cybersecurity ----
|
| 35 |
+
"https://feeds.feedburner.com/TheHackerNews",
|
| 36 |
+
"https://krebsonsecurity.com/feed/",
|
| 37 |
+
"https://www.bleepingcomputer.com/feed/",
|
| 38 |
+
"https://threatpost.com/feed/",
|
| 39 |
+
"https://therecord.media/feed/",
|
| 40 |
+
# ---- Funny / Weird ----
|
| 41 |
+
"https://www.theonion.com/rss",
|
| 42 |
+
"https://www.reddit.com/r/nottheonion/.rss",
|
| 43 |
+
"https://www.thedailymash.co.uk/feed",
|
| 44 |
+
"https://babylonbee.com/feed",
|
| 45 |
+
# ---- Gaming ----
|
| 46 |
+
"https://feeds.ign.com/ign/all",
|
| 47 |
+
"https://www.eurogamer.net/feed",
|
| 48 |
+
"https://www.pcgamer.com/rss/",
|
| 49 |
+
"https://www.kotaku.com/rss",
|
| 50 |
+
"https://www.gamespot.com/feeds/news/",
|
| 51 |
+
"https://www.polygon.com/rss/index.xml",
|
| 52 |
+
# ---- Movies ----
|
| 53 |
+
"https://variety.com/feed/",
|
| 54 |
+
"https://www.hollywoodreporter.com/feed/",
|
| 55 |
+
"https://deadline.com/feed/",
|
| 56 |
+
"https://screenrant.com/feed/",
|
| 57 |
+
# ---- Arab World ----
|
| 58 |
+
"https://www.arabnews.com/rss.xml",
|
| 59 |
+
"https://www.middleeasteye.net/rss",
|
| 60 |
+
"https://www.newarab.com/rss.xml",
|
| 61 |
+
"https://www.france24.com/en/middle-east/rss",
|
| 62 |
+
# ---- Tunisia ----
|
| 63 |
+
"https://www.tunisiaonlinenews.com/feed/",
|
| 64 |
+
"https://northafricapost.com/feed/",
|
| 65 |
+
"https://www.africanews.com/feed/",
|
| 66 |
+
])
|
| 67 |
+
|
| 68 |
+
# Fallback subreddits (used for --source reddit or --source rss)
|
| 69 |
+
news_subreddits: list[str] = field(default_factory=lambda: [
|
| 70 |
+
"worldnews", "news", "politics", "science", "technology",
|
| 71 |
+
"UpliftingNews", "economy", "geopolitics",
|
| 72 |
+
])
|
| 73 |
+
|
| 74 |
+
posts_per_subreddit: int = 3
|
| 75 |
+
|
| 76 |
+
weight_popularity: float = 0.20
|
| 77 |
+
weight_trustworthiness: float = 0.35
|
| 78 |
+
weight_coverage: float = 0.30
|
| 79 |
+
weight_recency: float = 0.15
|
| 80 |
+
|
| 81 |
+
user_interests: list[str] = field(default_factory=lambda: [
|
| 82 |
+
"artificial intelligence", "climate change", "health",
|
| 83 |
+
"economy", "space exploration", "cybersecurity",
|
| 84 |
+
"democracy", "science",
|
| 85 |
+
])
|
| 86 |
+
|
| 87 |
+
use_local_models: bool = False
|
| 88 |
+
summarization_model: str = "t5-small"
|
| 89 |
+
embedding_model: str = "all-MiniLM-L6-v2"
|
| 90 |
+
|
| 91 |
+
similarity_threshold: float = 0.70
|
| 92 |
+
|
| 93 |
+
port: int = int(os.getenv("PORT", "5050"))
|
| 94 |
+
host: str = os.getenv("HOST", "0.0.0.0")
|
| 95 |
+
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
| 96 |
+
flask_env: str = os.getenv("FLASK_ENV", "production")
|
| 97 |
+
cors_origins: str = os.getenv("CORS_ORIGINS", "*")
|
| 98 |
+
refresh_hour: int = int(os.getenv("REFRESH_HOUR", "4"))
|
| 99 |
+
refresh_minute: int = int(os.getenv("REFRESH_MINUTE", "0"))
|
| 100 |
+
refresh_timezone: str = os.getenv("REFRESH_TIMEZONE", "Africa/Tunis")
|
main.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
import sys
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
from config import Config
|
| 9 |
+
from src.models import Article, NewsItem
|
| 10 |
+
from src.extractor import ArticleExtractor
|
| 11 |
+
from src.analyzer import NewsAnalyzer
|
| 12 |
+
from src.aggregator import NewsAggregator
|
| 13 |
+
from src.presenter import NewsPresenter
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser(description="OneNews — RSS news aggregator")
|
| 20 |
+
parser.add_argument("--demo", action="store_true", help="Use sample data (no internet)")
|
| 21 |
+
parser.add_argument("--source", choices=["feeds", "hn", "reddit"], default=None,
|
| 22 |
+
help="Data source (default: RSS feeds)")
|
| 23 |
+
parser.add_argument("--models", action="store_true", help="Enable local ML models (slower)")
|
| 24 |
+
parser.add_argument("--subreddits", nargs="+", default=None, help="Override subreddits")
|
| 25 |
+
parser.add_argument("--limit", type=int, default=None, help="Posts per subreddit")
|
| 26 |
+
return parser.parse_args()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_pipeline(items: list[NewsItem], cfg: Config, skip_extraction: bool = False):
|
| 30 |
+
if not skip_extraction:
|
| 31 |
+
logger.info("═══ Extracting article content ═══")
|
| 32 |
+
extractor = ArticleExtractor()
|
| 33 |
+
for i, item in enumerate(items, 1):
|
| 34 |
+
article = extractor.extract(item.post.url)
|
| 35 |
+
if article:
|
| 36 |
+
logger.info(" [%2d/%d] %-60s ✓ (%s)", i, len(items), item.post.title[:60], article.source_domain)
|
| 37 |
+
else:
|
| 38 |
+
article = Article(
|
| 39 |
+
url=item.post.url,
|
| 40 |
+
title=item.post.title,
|
| 41 |
+
text=item.post.title,
|
| 42 |
+
source_domain=item.post.source_domain or "reddit.com",
|
| 43 |
+
extraction_success=False,
|
| 44 |
+
image_url=item.post.image_url,
|
| 45 |
+
)
|
| 46 |
+
logger.info(" [%2d/%d] %-60s ✗ (title only)", i, len(items), item.post.title[:60])
|
| 47 |
+
item.article = article
|
| 48 |
+
else:
|
| 49 |
+
logger.info("═══ Extraction skipped (articles already loaded) ═══")
|
| 50 |
+
|
| 51 |
+
logger.info("═══ Analysing articles ═══")
|
| 52 |
+
analyzer = NewsAnalyzer(cfg)
|
| 53 |
+
for i, item in enumerate(items, 1):
|
| 54 |
+
item.analysis = analyzer.analyze(item.article)
|
| 55 |
+
cat = item.analysis.category
|
| 56 |
+
topics = ", ".join(item.analysis.topics) if item.analysis.topics else "(none)"
|
| 57 |
+
logger.info(" [%2d/%d] %-14s topic: %-30s trust: %s", i, len(items), cat, topics, f"{item.analysis.trustworthiness_score:.0%}")
|
| 58 |
+
|
| 59 |
+
logger.info("═══ Clustering & ranking ═══")
|
| 60 |
+
aggregator = NewsAggregator(cfg)
|
| 61 |
+
clusters = aggregator.cluster_news(items)
|
| 62 |
+
logger.info(" → %d story clusters found", len(clusters))
|
| 63 |
+
|
| 64 |
+
return clusters
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname).1s %(message)s", stream=sys.stderr)
|
| 69 |
+
args = parse_args()
|
| 70 |
+
cfg = Config()
|
| 71 |
+
|
| 72 |
+
if args.models:
|
| 73 |
+
cfg.use_local_models = True
|
| 74 |
+
logging.getLogger().setLevel(logging.DEBUG)
|
| 75 |
+
if args.subreddits:
|
| 76 |
+
cfg.news_subreddits = args.subreddits
|
| 77 |
+
if args.limit:
|
| 78 |
+
cfg.posts_per_subreddit = args.limit
|
| 79 |
+
|
| 80 |
+
total_start = time.perf_counter()
|
| 81 |
+
|
| 82 |
+
source = args.source or "feeds"
|
| 83 |
+
|
| 84 |
+
if args.demo:
|
| 85 |
+
from src.mockdata import generate_demo_items
|
| 86 |
+
print("═══ Loading demo data ═══")
|
| 87 |
+
items = generate_demo_items()
|
| 88 |
+
print(f" → {len(items)} sample articles loaded\n")
|
| 89 |
+
clusters = run_pipeline(items, cfg, skip_extraction=True)
|
| 90 |
+
n_posts = len(items)
|
| 91 |
+
|
| 92 |
+
elif source == "hn":
|
| 93 |
+
from src.hn_scraper import HackerNewsScraper
|
| 94 |
+
print("═══ Scraping Hacker News ═══")
|
| 95 |
+
scraper = HackerNewsScraper(cfg)
|
| 96 |
+
posts = scraper.fetch_posts()
|
| 97 |
+
n_posts = len(posts)
|
| 98 |
+
print(f" → {n_posts} posts collected\n")
|
| 99 |
+
if not posts:
|
| 100 |
+
sys.exit(1)
|
| 101 |
+
items = [NewsItem(post=p) for p in posts]
|
| 102 |
+
clusters = run_pipeline(items, cfg)
|
| 103 |
+
|
| 104 |
+
elif source == "reddit":
|
| 105 |
+
from src.scraper import RedditScraper
|
| 106 |
+
print("═══ Scraping Reddit ═══")
|
| 107 |
+
scraper = RedditScraper(cfg)
|
| 108 |
+
posts = scraper.fetch_posts()
|
| 109 |
+
n_posts = len(posts)
|
| 110 |
+
print(f" → {n_posts} posts collected\n")
|
| 111 |
+
if not posts:
|
| 112 |
+
sys.exit(1)
|
| 113 |
+
items = [NewsItem(post=p) for p in posts]
|
| 114 |
+
clusters = run_pipeline(items, cfg)
|
| 115 |
+
|
| 116 |
+
else:
|
| 117 |
+
from src.rss_feed_scraper import RSSFeedScraper
|
| 118 |
+
print("═══ Fetching RSS news feeds ═══")
|
| 119 |
+
scraper = RSSFeedScraper(cfg)
|
| 120 |
+
posts = scraper.fetch_posts()
|
| 121 |
+
n_posts = len(posts)
|
| 122 |
+
print(f" → {n_posts} posts collected\n")
|
| 123 |
+
if not posts:
|
| 124 |
+
sys.exit(1)
|
| 125 |
+
items = [NewsItem(post=p) for p in posts]
|
| 126 |
+
clusters = run_pipeline(items, cfg)
|
| 127 |
+
|
| 128 |
+
elapsed = time.perf_counter() - total_start
|
| 129 |
+
print(f"\n Done in {elapsed:.1f}s — {n_posts} posts, {len(clusters)} clusters\n")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
main()
|
refresh_task.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Standalone script for PythonAnywhere scheduled tasks.
|
| 4 |
+
Run daily at 3:00 UTC (4:00 AM Tunisian time).
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python3 /home/YOUR_USER/newsapp/refresh_task.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
|
| 17 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname).1s %(message)s", stream=sys.stderr)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
|
| 22 |
+
from config import Config
|
| 23 |
+
from main import run_pipeline
|
| 24 |
+
from src.models import NewsItem
|
| 25 |
+
from src.rss_feed_scraper import RSSFeedScraper
|
| 26 |
+
|
| 27 |
+
cfg = Config()
|
| 28 |
+
|
| 29 |
+
CACHE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache_data.json")
|
| 30 |
+
|
| 31 |
+
CATEGORIES = ["Geopolitical", "World Health", "Tech", "Cybersecurity", "Funny/Weird", "Gaming", "Movies", "Arab World", "Tunisia"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def serialize_clusters(clusters):
|
| 35 |
+
by_cat = defaultdict(list)
|
| 36 |
+
results = []
|
| 37 |
+
for c in clusters:
|
| 38 |
+
cat = "General"
|
| 39 |
+
if c.articles and c.articles[0].analysis:
|
| 40 |
+
ca = c.articles[0].analysis.category
|
| 41 |
+
cat = ca if ca in CATEGORIES else "General"
|
| 42 |
+
|
| 43 |
+
html = {
|
| 44 |
+
"category": cat,
|
| 45 |
+
"topic": c.topic,
|
| 46 |
+
"score": round(c.final_score, 2),
|
| 47 |
+
"trust": f"{c.avg_trustworthiness:.0%}",
|
| 48 |
+
"coverage": c.total_coverage,
|
| 49 |
+
"image_url": c.image_url,
|
| 50 |
+
"top_post_url": c.top_post_url,
|
| 51 |
+
"articles": [
|
| 52 |
+
{
|
| 53 |
+
"title": a.post.title,
|
| 54 |
+
"domain": a.article.source_domain if a.article else "",
|
| 55 |
+
"summary": a.analysis.summary if a.analysis else "",
|
| 56 |
+
"topics": a.analysis.topics if a.analysis else [],
|
| 57 |
+
"trust": f"{a.analysis.trustworthiness_score:.0%}" if a.analysis else "",
|
| 58 |
+
"score": a.post.score,
|
| 59 |
+
"comments": a.post.num_comments,
|
| 60 |
+
"url": a.post.url,
|
| 61 |
+
"image": a.article.image_url if a.article else a.post.image_url,
|
| 62 |
+
"published": a.post.published,
|
| 63 |
+
"published_iso": a.post.published_iso,
|
| 64 |
+
}
|
| 65 |
+
for a in c.articles[:5]
|
| 66 |
+
],
|
| 67 |
+
}
|
| 68 |
+
by_cat[cat].append(html)
|
| 69 |
+
|
| 70 |
+
api = {
|
| 71 |
+
"id": f"cluster-{id(c)}",
|
| 72 |
+
"topic": c.topic,
|
| 73 |
+
"category": cat,
|
| 74 |
+
"final_score": round(c.final_score, 2),
|
| 75 |
+
"avg_trustworthiness": round(c.avg_trustworthiness, 2),
|
| 76 |
+
"total_coverage": c.total_coverage,
|
| 77 |
+
"image_url": c.image_url,
|
| 78 |
+
"top_post_url": c.top_post_url,
|
| 79 |
+
"articles": [
|
| 80 |
+
{
|
| 81 |
+
"title": a.post.title,
|
| 82 |
+
"url": a.post.url,
|
| 83 |
+
"domain": a.article.source_domain if a.article else "",
|
| 84 |
+
"summary": a.analysis.summary if a.analysis else "",
|
| 85 |
+
"topics": a.analysis.topics if a.analysis else [],
|
| 86 |
+
"trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
|
| 87 |
+
"score": a.post.score,
|
| 88 |
+
"comments": a.post.num_comments,
|
| 89 |
+
"image": a.article.image_url if a.article else a.post.image_url,
|
| 90 |
+
"published": a.post.published,
|
| 91 |
+
"published_iso": a.post.published_iso,
|
| 92 |
+
}
|
| 93 |
+
for a in c.articles[:5]
|
| 94 |
+
],
|
| 95 |
+
}
|
| 96 |
+
results.append(api)
|
| 97 |
+
|
| 98 |
+
data = {
|
| 99 |
+
"by_cat": dict(by_cat),
|
| 100 |
+
"results": results,
|
| 101 |
+
"status": f"ok — {len(clusters)} clusters",
|
| 102 |
+
"last_refresh": datetime.now(timezone.utc).isoformat(),
|
| 103 |
+
}
|
| 104 |
+
with open(CACHE_FILE, "w") as f:
|
| 105 |
+
json.dump(data, f)
|
| 106 |
+
logger.info("Cache written to %s (%d clusters)", CACHE_FILE, len(clusters))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main():
|
| 110 |
+
logger.info("Starting refresh task...")
|
| 111 |
+
scraper = RSSFeedScraper(cfg)
|
| 112 |
+
posts = scraper.fetch_posts()
|
| 113 |
+
items = [NewsItem(post=p) for p in posts]
|
| 114 |
+
clusters = run_pipeline(items, cfg)
|
| 115 |
+
serialize_clusters(clusters)
|
| 116 |
+
logger.info("Refresh task done — %d clusters", len(clusters))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core
|
| 2 |
+
flask>=3.0.0
|
| 3 |
+
flask-cors>=4.0.0
|
| 4 |
+
gunicorn>=21.2.0
|
| 5 |
+
feedparser>=6.0.0
|
| 6 |
+
requests>=2.28.0
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
trafilatura>=1.6.0
|
| 9 |
+
beautifulsoup4>=4.12.0
|
| 10 |
+
apscheduler>=3.10.0
|
| 11 |
+
|
| 12 |
+
# Reddit (optional — only needed for Reddit source)
|
| 13 |
+
praw>=7.7.0
|
| 14 |
+
|
| 15 |
+
# NLP / GenAI (optional — program falls back to rule-based if missing)
|
| 16 |
+
# transformers>=4.30.0
|
| 17 |
+
# sentence-transformers>=2.2.0
|
| 18 |
+
# torch>=2.0.0
|
| 19 |
+
# numpy>=1.24.0
|
| 20 |
+
|
| 21 |
+
# Install with: pip install -r requirements.txt
|
| 22 |
+
# For ML models: pip install transformers sentence-transformers torch numpy
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (112 Bytes). View file
|
|
|
src/__pycache__/aggregator.cpython-312.pyc
ADDED
|
Binary file (8.64 kB). View file
|
|
|
src/__pycache__/analyzer.cpython-312.pyc
ADDED
|
Binary file (21.8 kB). View file
|
|
|
src/__pycache__/extractor.cpython-312.pyc
ADDED
|
Binary file (5.58 kB). View file
|
|
|
src/__pycache__/hn_scraper.cpython-312.pyc
ADDED
|
Binary file (3.18 kB). View file
|
|
|
src/__pycache__/mockdata.cpython-312.pyc
ADDED
|
Binary file (8.43 kB). View file
|
|
|
src/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (2.4 kB). View file
|
|
|
src/__pycache__/presenter.cpython-312.pyc
ADDED
|
Binary file (4.32 kB). View file
|
|
|
src/__pycache__/rss_feed_scraper.cpython-312.pyc
ADDED
|
Binary file (8.06 kB). View file
|
|
|
src/__pycache__/rss_scraper.cpython-312.pyc
ADDED
|
Binary file (5.4 kB). View file
|
|
|
src/__pycache__/scraper.cpython-312.pyc
ADDED
|
Binary file (5.33 kB). View file
|
|
|
src/aggregator.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from .models import NewsCluster, NewsItem
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class NewsAggregator:
|
| 11 |
+
def __init__(self, config):
|
| 12 |
+
self.config = config
|
| 13 |
+
self._encoder = None
|
| 14 |
+
self._setup_encoder()
|
| 15 |
+
|
| 16 |
+
def _setup_encoder(self):
|
| 17 |
+
if not self.config.use_local_models:
|
| 18 |
+
logger.info("Local models disabled — using keyword similarity")
|
| 19 |
+
return
|
| 20 |
+
try:
|
| 21 |
+
from sentence_transformers import SentenceTransformer
|
| 22 |
+
model_name = f"sentence-transformers/{self.config.embedding_model}"
|
| 23 |
+
logger.info("Loading embedding model: %s ...", model_name)
|
| 24 |
+
self._encoder = SentenceTransformer(model_name)
|
| 25 |
+
except ImportError:
|
| 26 |
+
logger.warning("sentence-transformers not available — using keyword fallback")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
logger.warning("Embedding model failed: %s", exc)
|
| 29 |
+
|
| 30 |
+
def compute_similarity(self, a: str, b: str) -> float:
|
| 31 |
+
if self._encoder:
|
| 32 |
+
emb_a = self._encoder.encode(a, normalize_embeddings=True)
|
| 33 |
+
emb_b = self._encoder.encode(b, normalize_embeddings=True)
|
| 34 |
+
return float(emb_a @ emb_b)
|
| 35 |
+
return self._keyword_overlap(a, b)
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
def _keyword_overlap(a: str, b: str) -> float:
|
| 39 |
+
words_a = set(a.lower().split())
|
| 40 |
+
words_b = set(b.lower().split())
|
| 41 |
+
if not words_a or not words_b:
|
| 42 |
+
return 0.0
|
| 43 |
+
common = words_a & words_b
|
| 44 |
+
return len(common) / max(len(words_a), len(words_b))
|
| 45 |
+
|
| 46 |
+
def cluster_news(self, items: list[NewsItem]) -> list[NewsCluster]:
|
| 47 |
+
clusters: list[list[NewsItem]] = []
|
| 48 |
+
|
| 49 |
+
for item in items:
|
| 50 |
+
if not item.analysis or not item.article:
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
text = f"{item.post.title} {item.analysis.summary}"
|
| 54 |
+
placed = False
|
| 55 |
+
|
| 56 |
+
for cluster in clusters:
|
| 57 |
+
rep = cluster[0]
|
| 58 |
+
if not rep.analysis:
|
| 59 |
+
continue
|
| 60 |
+
rep_text = f"{rep.post.title} {rep.analysis.summary}"
|
| 61 |
+
if self.compute_similarity(text, rep_text) >= self.config.similarity_threshold:
|
| 62 |
+
cluster.append(item)
|
| 63 |
+
placed = True
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
if not placed:
|
| 67 |
+
clusters.append([item])
|
| 68 |
+
|
| 69 |
+
return self._rank_clusters(clusters)
|
| 70 |
+
|
| 71 |
+
def _rank_clusters(self, raw: list[list[NewsItem]]) -> list[NewsCluster]:
|
| 72 |
+
scored = []
|
| 73 |
+
for group in raw:
|
| 74 |
+
if not group:
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
topic = self._main_topic(group)
|
| 78 |
+
|
| 79 |
+
# Highest scoring post represents the cluster
|
| 80 |
+
best = max(group, key=lambda x: x.post.score)
|
| 81 |
+
|
| 82 |
+
avg_trust = sum(
|
| 83 |
+
it.analysis.trustworthiness_score for it in group if it.analysis
|
| 84 |
+
) / max(len(group), 1)
|
| 85 |
+
|
| 86 |
+
avg_pop = sum(it.post.score for it in group) / max(len(group), 1)
|
| 87 |
+
|
| 88 |
+
# Pick the first non-empty image
|
| 89 |
+
image_url = ""
|
| 90 |
+
for it in group:
|
| 91 |
+
src = it.article.image_url if it.article else ""
|
| 92 |
+
if src:
|
| 93 |
+
image_url = src
|
| 94 |
+
break
|
| 95 |
+
if it.post.image_url:
|
| 96 |
+
image_url = it.post.image_url
|
| 97 |
+
break
|
| 98 |
+
|
| 99 |
+
cluster_score = self._cluster_score(group, avg_trust)
|
| 100 |
+
|
| 101 |
+
cluster = NewsCluster(
|
| 102 |
+
topic=topic,
|
| 103 |
+
articles=group,
|
| 104 |
+
total_coverage=len(group),
|
| 105 |
+
avg_trustworthiness=avg_trust,
|
| 106 |
+
avg_popularity=avg_pop,
|
| 107 |
+
top_post_url=best.post.url,
|
| 108 |
+
final_score=cluster_score,
|
| 109 |
+
image_url=image_url,
|
| 110 |
+
)
|
| 111 |
+
scored.append(cluster)
|
| 112 |
+
|
| 113 |
+
return sorted(scored, key=lambda c: c.final_score, reverse=True)
|
| 114 |
+
|
| 115 |
+
def _cluster_score(self, group: list[NewsItem], avg_trust: float) -> float:
|
| 116 |
+
scores = []
|
| 117 |
+
for item in group:
|
| 118 |
+
s = avg_trust * 0.50
|
| 119 |
+
|
| 120 |
+
# Content quality: longer articles score higher
|
| 121 |
+
if item.article and item.article.text:
|
| 122 |
+
title_len = len(item.article.title or "")
|
| 123 |
+
text_len = len(item.article.text)
|
| 124 |
+
if text_len > title_len * 3:
|
| 125 |
+
s += 0.15
|
| 126 |
+
elif text_len > title_len * 1.5:
|
| 127 |
+
s += 0.08
|
| 128 |
+
|
| 129 |
+
# Successfully extracted vs title-only
|
| 130 |
+
if item.article and item.article.extraction_success:
|
| 131 |
+
s += 0.10
|
| 132 |
+
else:
|
| 133 |
+
s -= 0.10
|
| 134 |
+
|
| 135 |
+
# More topics = richer article
|
| 136 |
+
if item.analysis and item.analysis.topics:
|
| 137 |
+
s += min(len(item.analysis.topics) * 0.06, 0.18)
|
| 138 |
+
|
| 139 |
+
# Having a category means we actually understood it
|
| 140 |
+
if item.analysis and item.analysis.category != "General":
|
| 141 |
+
s += 0.05
|
| 142 |
+
|
| 143 |
+
scores.append(max(0.05, min(1.0, s)))
|
| 144 |
+
|
| 145 |
+
return sum(scores) / max(len(scores), 1)
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _main_topic(cluster: list[NewsItem]) -> str:
|
| 149 |
+
counter: Counter[str] = Counter()
|
| 150 |
+
for item in cluster:
|
| 151 |
+
if item.analysis:
|
| 152 |
+
for t in item.analysis.topics:
|
| 153 |
+
counter[t] += 1
|
| 154 |
+
if counter:
|
| 155 |
+
return counter.most_common(1)[0][0]
|
| 156 |
+
return (cluster[0].post.title or "")[:60]
|
src/analyzer.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import html
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from .models import Analysis, Article
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
RELIABLE_DOMAINS = {
|
| 11 |
+
"reuters.com": 0.20, "apnews.com": 0.20, "bbc.com": 0.15,
|
| 12 |
+
"bbc.co.uk": 0.15, "npr.org": 0.12, "wsj.com": 0.12,
|
| 13 |
+
"economist.com": 0.15, "nature.com": 0.20, "science.org": 0.18,
|
| 14 |
+
"sciencedaily.com": 0.14, "theguardian.com": 0.08, "nytimes.com": 0.10,
|
| 15 |
+
"washingtonpost.com": 0.10, "ft.com": 0.14, "bloomberg.com": 0.12,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
UNRELIABLE_DOMAINS = {
|
| 19 |
+
"infowars.com": -0.30, "breitbart.com": -0.20, "dailymail.co.uk": -0.12,
|
| 20 |
+
"theonion.com": -0.25, "naturalnews.com": -0.30, "zerohedge.com": -0.15,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
CLICKBAIT_PATTERNS = [
|
| 24 |
+
r"you won'?t believe", r"shocked?", r"gobsmacked",
|
| 25 |
+
r"this is what happens", r"number \d+ will",
|
| 26 |
+
r"here'?s why", r"what happens next",
|
| 27 |
+
r"blown away", r"mind.?blowing",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
OPINION_MARKERS = [
|
| 31 |
+
r"\bi think\b", r"\bin my opinion\b", r"\bpersonally\b",
|
| 32 |
+
r"\bi believe\b", r"\bclearly\b", r"\bobviously\b",
|
| 33 |
+
r"\bin my view\b", r"\bit seems\b", r"\bi feel\b",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
LEFT_KEYWORDS = ["progressive", "equality", "social justice", "climate crisis",
|
| 37 |
+
"marginalized", "systemic", "privilege", "inequality"]
|
| 38 |
+
RIGHT_KEYWORDS = ["deregulation", "tax cuts", "free market", "traditional",
|
| 39 |
+
"sovereignty", "patriot", "heritage", "small government"]
|
| 40 |
+
|
| 41 |
+
TOPIC_MAP: dict[str, list[str]] = {
|
| 42 |
+
"artificial intelligence": [r"\bai\b", r"\bartificial intelligence\b",
|
| 43 |
+
r"\bmachine learning\b", r"\bgpt\b", r"\bllm\b",
|
| 44 |
+
r"\bneural network", r"\bdeep learning\b"],
|
| 45 |
+
"climate change": [r"\bclimate\b", r"\bglobal warming\b", r"\bemissions\b",
|
| 46 |
+
r"\bcarbon\b", r"\brenewable\b", r"\bsolar\b", r"\bwind turbine",
|
| 47 |
+
r"\bheatwave\b", r"\bextreme weather\b", r"\bheat wave\b"],
|
| 48 |
+
"health": [r"\bhealth\b", r"\bcovid\b", r"\bvaccine\b", r"\bdisease\b",
|
| 49 |
+
r"\bhospital\b", r"\bmedical\b", r"\bcancer\b", r"\bdrug\b",
|
| 50 |
+
r"\bod\b", r"\bpandemic\b", r"\bpatient\b", r"\bsurgery\b",
|
| 51 |
+
r"\bdoctor\b", r"\bnurse\b", r"\btreatment\b", r"\btherapy\b",
|
| 52 |
+
r"\bdementia\b", r"\bdiabetes\b", r"\bobesity\b", r"\bmental health\b",
|
| 53 |
+
r"\babortion\b", r"\bpregnant\b", r"\bmedicine\b", r"\bclinical\b",
|
| 54 |
+
r"\bsymptom\b", r"\bheat\b", r"\brabies\b", r"\bfever\b"],
|
| 55 |
+
"economy": [r"\beconomy\b", r"\binflation\b", r"\bgdp\b", r"\binterest rate\b",
|
| 56 |
+
r"\brecession\b", r"\bunemployment\b", r"\bmarket\b", r"\btariff\b",
|
| 57 |
+
r"\btrade war\b", r"\bdebt\b", r"\bstock\b", r"\bprice\b", r"\bcost\b",
|
| 58 |
+
r"\bfinancial\b"],
|
| 59 |
+
"space": [r"\bspace\b", r"\bnasa\b", r"\bspacex\b", r"\bmars\b", r"\brocket\b",
|
| 60 |
+
r"\bastronaut\b", r"\bgalaxy\b", r"\bplanet\b", r"\borgbit\b",
|
| 61 |
+
r"\bstellar\b", r"\bcosmic\b"],
|
| 62 |
+
"cybersecurity": [r"\bcyber\b", r"\bhack", r"\bsecurity breach\b",
|
| 63 |
+
r"\bdata breach\b", r"\bransomware\b", r"\bmalware\b",
|
| 64 |
+
r"\bphishing\b", r"\bzero.day\b", r"\bfirewall\b",
|
| 65 |
+
r"\bencryption\b", r"\bCVE\b", r"\bexploit\b",
|
| 66 |
+
r"\bbotnet\b", r"\bDDoS\b", r"\bvulnerability\b", r"\bfraud\b"],
|
| 67 |
+
"politics": [r"\belection\b", r"\bvot(?:e|ing|er)\b", r"\bcongress\b",
|
| 68 |
+
r"\bparliament\b", r"\bsenate\b", r"\bpresident\b",
|
| 69 |
+
r"\bgovern(?:ment|or)\b", r"\bGOP\b", r"\bDemocrat\b",
|
| 70 |
+
r"\brepublican\b", r"\bpolitician\b", r"\bcandidate\b",
|
| 71 |
+
r"\bambassador\b", r"\bdiplomat\b", r"\bsanction\b",
|
| 72 |
+
r"\btreaty\b", r"\bembassy\b", r"\bminister\b", r"\bregime\b",
|
| 73 |
+
r"\blegislat\b", r"\bpolicy\b", r"\bfederal\b"],
|
| 74 |
+
"science": [r"\bscien(?:ce|tist|tists|tific)\b", r"\bresearch\b", r"\bstudy\b",
|
| 75 |
+
r"\bdiscovery\b", r"\bgenome\b", r"\bquantum\b", r"\bparticle\b",
|
| 76 |
+
r"\bevolution\b", r"\bexperiment\b", r"\bjournal\b", r"\blab\b",
|
| 77 |
+
r"\bDNA\b", r"\bgene\b", r"\bprotein\b", r"\bbiolog\b",
|
| 78 |
+
r"\bchemical\b", r"\bphysics\b"],
|
| 79 |
+
"technology": [r"\btech\b", r"\bsoftware\b", r"\bhardware\b", r"\bchip\b",
|
| 80 |
+
r"\bsemiconductor\b", r"\bapp\b", r"\balgorithm\b",
|
| 81 |
+
r"\bcomputer\b", r"\brobot\b", r"\bgaming\b", r"\bvideo game\b",
|
| 82 |
+
r"\bconsole\b", r"\bmobile\b", r"\bphone\b", r"\blaptop\b",
|
| 83 |
+
r"\bsmartphone\b", r"\bgadget\b", r"\bstartup\b",
|
| 84 |
+
r"\bplatform\b", r"\bdeveloper\b", r"\bcode\b", r"\bprogramming\b",
|
| 85 |
+
r"\bdigital\b", r"\bcloud\b", r"\bdevice\b", r"\bsmart\b",
|
| 86 |
+
r"\bIoT\b", r"\bOS\b", r"\bWindows\b", r"\bAndroid\b", r"\biOS\b",
|
| 87 |
+
r"\bPlayStation\b", r"\bapp\b", r"\bAI\b", r"\bA\.I",
|
| 88 |
+
r"\bEV\b", r"\belectric vehicle\b", r"\bgadget\b",
|
| 89 |
+
r"\btechlash\b"],
|
| 90 |
+
"sports": [r"\bsport\b", r"\bfootball\b", r"\bsoccer\b", r"\bbasketball\b",
|
| 91 |
+
r"\btennis\b", r"\bworld cup\b", r"\bolympic\b"],
|
| 92 |
+
"education": [r"\beducation\b", r"\bschool\b", r"\buniversity\b",
|
| 93 |
+
r"\bstudent\b", r"\bteacher\b", r"\bcollege\b", r"\bcampus\b"],
|
| 94 |
+
"immigration": [r"\bimmigra(?:nt|tion)\b", r"\bborder\b", r"\basylum\b",
|
| 95 |
+
r"\brefugee\b", r"\bdeport\b", r"\bvisa\b"],
|
| 96 |
+
"energy": [r"\boil\b", r"\bgas\b", r"\bnuclear\b", r"\benergy\b",
|
| 97 |
+
r"\bpower plant\b", r"\brenewable\b", r"\bfossil fuel\b"],
|
| 98 |
+
"world": [r"\bwar\b", r"\bmilitary\b", r"\binvasion\b", r"\bsanction\b",
|
| 99 |
+
r"\bforeign\b", r"\bdiplomat\b", r"\btreaty\b", r"\bconflict\b",
|
| 100 |
+
r"\bearthquake\b", r"\bflood\b", r"\bdisaster\b", r"\bpresident\b",
|
| 101 |
+
r"\bprime minister\b", r"\bgeopolitic\b", r"\balliance\b",
|
| 102 |
+
r"\bmilitant\b", r"\bguerrilla\b", r"\bceasefire\b", r"\bterrorism\b",
|
| 103 |
+
r"\bUkraine\b", r"\bRussia\b", r"\bChina\b", r"\bIran\b",
|
| 104 |
+
r"\batomic\b", r"\bnuclear\b", r"\bmissile\b", r"\bdrone\b",
|
| 105 |
+
r"\battack\b", r"\bstrike\b", r"\bbomb\b", r"\btroop\b",
|
| 106 |
+
r"\bsoldier\b", r"\bmissile\b", r"\bdefence\b", r"\bdefense\b",
|
| 107 |
+
r"\bNATO\b", r"\bUN\b", r"\bICC\b", r"\bintelligence\b",
|
| 108 |
+
r"\bVatican\b", r"\bCatholic\b"],
|
| 109 |
+
"funny": [r"\bfunny\b", r"\bjoke\b", r"\bhumor\b", r"\bcomedy\b",
|
| 110 |
+
r"\bsatire\b", r"\bparody\b", r"\blol\b", r"\bwtf\b",
|
| 111 |
+
r"\babsurd\b", r"\bridiculous\b", r"\bhilarious\b", r"\bcomic\b",
|
| 112 |
+
r"\blaugh\b", r"\bclown\b"],
|
| 113 |
+
"weird": [r"\bweird\b", r"\bstrange\b", r"\bbizarre\b", r"\boddb?all\b",
|
| 114 |
+
r"\bpeculiar\b", r"\bunusual\b", r"\bodd\b", r"\bunbelievable\b",
|
| 115 |
+
r"\bsurreal\b", r"\bunconventional\b", r"\bwtf\b"],
|
| 116 |
+
"onion": [r"\bonion\b", r"\btheonion\b"],
|
| 117 |
+
"gaming": [r"\bgam(?:e|ing|er|ers)\b", r"\besport\b", r"\bplaystation\b",
|
| 118 |
+
r"\bxbox\b", r"\bnintendo\b", r"\bsteam\b", r"\bconsole\b",
|
| 119 |
+
r"\bgta\b", r"\bgrand theft auto\b", r"\bfortnite\b",
|
| 120 |
+
r"\bminecraft\b", r"\bvalorant\b", r"\bvideogame\b",
|
| 121 |
+
r"\bvideo game\b"],
|
| 122 |
+
"movies": [r"\bmovie\b", r"\bfilm\b", r"\bcinema\b", r"\bHollywood\b",
|
| 123 |
+
r"\bbox office\b", r"\bblockbuster\b", r"\bOscar\b",
|
| 124 |
+
r"\bactor\b", r"\bactress\b", r"\bscreenplay\b",
|
| 125 |
+
r"\bdirector\b", r"\bNetflix\b", r"\bDisney\+\b",
|
| 126 |
+
r"\bHBO\b", r"\breboot\b", r"\bsequel\b", r"\bprequel\b",
|
| 127 |
+
r"\bIMAX\b", r"\banimation\b"],
|
| 128 |
+
"tunisia": [r"\bTunisia\b", r"\bTunis\b", r"\bCarthage\b",
|
| 129 |
+
r"\bSousse\b", r"\bSfax\b"],
|
| 130 |
+
"arab_world": [r"\barab\b", r"\bgulf\b", r"\bmiddle east\b",
|
| 131 |
+
r"\bsaudi\b", r"\bQatar\b", r"\bUAE\b", r"\bDubai\b",
|
| 132 |
+
r"\bAbu Dhabi\b", r"\bDoha\b", r"\bRiyadh\b",
|
| 133 |
+
r"\bPalestin\b", r"\bGaza\b", r"\bWest Bank\b",
|
| 134 |
+
r"\bLeban\b", r"\bBeirut\b", r"\bBaghdad\b",
|
| 135 |
+
r"\bCairo\b", r"\bEgypt\b", r"\bSyria\b",
|
| 136 |
+
r"\bYemen\b", r"\bAmman\b", r"\bJordan\b",
|
| 137 |
+
r"\bOman\b", r"\bKuwait\b", r"\bBahrain\b",
|
| 138 |
+
r"\bUnrwa\b", r"\bHezbollah\b", r"\bHouthi\b",
|
| 139 |
+
r"\bOPEC\b", r"\bMENA\b"],
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
TOPIC_TO_CATEGORY: dict[str, str] = {
|
| 143 |
+
"politics": "Geopolitical",
|
| 144 |
+
"world": "Geopolitical",
|
| 145 |
+
"immigration": "Geopolitical",
|
| 146 |
+
"economy": "Geopolitical",
|
| 147 |
+
"energy": "Geopolitical",
|
| 148 |
+
"health": "World Health",
|
| 149 |
+
"science": "World Health",
|
| 150 |
+
"technology": "Tech",
|
| 151 |
+
"artificial intelligence": "Tech",
|
| 152 |
+
"space": "Tech",
|
| 153 |
+
"cybersecurity": "Cybersecurity",
|
| 154 |
+
"funny": "Funny/Weird",
|
| 155 |
+
"weird": "Funny/Weird",
|
| 156 |
+
"onion": "Funny/Weird",
|
| 157 |
+
"sports": "Funny/Weird",
|
| 158 |
+
"education": "Geopolitical",
|
| 159 |
+
"climate change": "Geopolitical",
|
| 160 |
+
"gaming": "Gaming",
|
| 161 |
+
"movies": "Movies",
|
| 162 |
+
"tunisia": "Tunisia",
|
| 163 |
+
"arab_world": "Arab World",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
FACTUAL_KEYWORDS = [
|
| 167 |
+
r"\breport\b", r"\baccording to\b", r"\bsource said\b", r"\bstated\b",
|
| 168 |
+
r"\bstudy found\b", r"\bdata show\b", r"\bofficial said\b",
|
| 169 |
+
r"\bresearch suggests\b", r"\bthe study\b", r"\bsurvey\b",
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class NewsAnalyzer:
|
| 174 |
+
def __init__(self, config):
|
| 175 |
+
self.config = config
|
| 176 |
+
self._summariser = None
|
| 177 |
+
self._classifier = None
|
| 178 |
+
self._setup_models()
|
| 179 |
+
|
| 180 |
+
def _setup_models(self):
|
| 181 |
+
if not self.config.use_local_models:
|
| 182 |
+
logger.info("Local models disabled — using rule-based analysis")
|
| 183 |
+
return
|
| 184 |
+
try:
|
| 185 |
+
from transformers import pipeline
|
| 186 |
+
logger.info("Loading summariser: %s ...", self.config.summarization_model)
|
| 187 |
+
self._summariser = pipeline(
|
| 188 |
+
"summarization",
|
| 189 |
+
model=self.config.summarization_model,
|
| 190 |
+
tokenizer=self.config.summarization_model,
|
| 191 |
+
)
|
| 192 |
+
logger.info("Loading zero-shot classifier ...")
|
| 193 |
+
self._classifier = pipeline(
|
| 194 |
+
"zero-shot-classification",
|
| 195 |
+
model="typeform/distilbert-base-uncased-mnli",
|
| 196 |
+
)
|
| 197 |
+
except ImportError:
|
| 198 |
+
logger.warning("transformers not available — using rule-based analysis")
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
logger.warning("Model loading failed: %s — using rule-based", exc)
|
| 201 |
+
|
| 202 |
+
def analyze(self, article: Article) -> Analysis:
|
| 203 |
+
summary = self._summarise(article)
|
| 204 |
+
topics = self._classify_topics(article, summary)
|
| 205 |
+
trust = self._assess_trustworthiness(article)
|
| 206 |
+
is_opinion = self._detect_opinion(article.text or "")
|
| 207 |
+
leaning = self._detect_political_leaning(article.text or "")
|
| 208 |
+
|
| 209 |
+
category = self._map_category(topics)
|
| 210 |
+
|
| 211 |
+
return Analysis(
|
| 212 |
+
summary=summary,
|
| 213 |
+
topics=topics,
|
| 214 |
+
trustworthiness_score=trust,
|
| 215 |
+
is_opinion=is_opinion,
|
| 216 |
+
political_leaning=leaning,
|
| 217 |
+
category=category,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def _summarise(self, article: Article) -> str:
|
| 221 |
+
title = article.title or ""
|
| 222 |
+
text = article.text or ""
|
| 223 |
+
|
| 224 |
+
if self._summariser:
|
| 225 |
+
try:
|
| 226 |
+
input_text = text[:1024]
|
| 227 |
+
out = self._summariser(input_text, max_length=130, min_length=30,
|
| 228 |
+
do_sample=False)
|
| 229 |
+
return out[0]["summary_text"]
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
logger.debug("Summariser failed: %s", exc)
|
| 232 |
+
|
| 233 |
+
body = self._strip_metadata(text)
|
| 234 |
+
body = self._strip_title_line(body, title)
|
| 235 |
+
|
| 236 |
+
if not body or len(body) < len(title) * 1.5:
|
| 237 |
+
return ""
|
| 238 |
+
|
| 239 |
+
title_norm = self._norm(title)
|
| 240 |
+
sentences = re.split(r"(?<=[.!?])\s+", body.strip())
|
| 241 |
+
selected = []
|
| 242 |
+
for s in sentences:
|
| 243 |
+
s = s.strip()
|
| 244 |
+
if not s:
|
| 245 |
+
continue
|
| 246 |
+
if self._is_title_like(s, title_norm):
|
| 247 |
+
continue
|
| 248 |
+
selected.append(s)
|
| 249 |
+
if len(selected) >= 2:
|
| 250 |
+
break
|
| 251 |
+
return " ".join(selected) if selected else ""
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
def _norm(s: str) -> str:
|
| 255 |
+
return re.sub(r"\s+", " ", html.unescape(s).lower().strip()).rstrip(".")
|
| 256 |
+
|
| 257 |
+
@staticmethod
|
| 258 |
+
def _is_title_like(sentence: str, title_norm: str) -> bool:
|
| 259 |
+
s_norm = re.sub(r"\s+", " ", sentence.lower().strip()).rstrip(".")
|
| 260 |
+
if s_norm == title_norm:
|
| 261 |
+
return True
|
| 262 |
+
words_s = set(s_norm.split())
|
| 263 |
+
words_t = set(title_norm.split())
|
| 264 |
+
if not words_s or not words_t:
|
| 265 |
+
return False
|
| 266 |
+
short, long = (words_s, words_t) if len(words_s) < len(words_t) else (words_t, words_s)
|
| 267 |
+
overlap = len(short & long) / max(len(short), len(long))
|
| 268 |
+
return overlap > 0.7
|
| 269 |
+
|
| 270 |
+
@staticmethod
|
| 271 |
+
def _strip_title_line(text: str, title: str) -> str:
|
| 272 |
+
lines = text.split("\n")
|
| 273 |
+
if not lines:
|
| 274 |
+
return text
|
| 275 |
+
first = lines[0].strip()
|
| 276 |
+
if not first:
|
| 277 |
+
return "\n".join(lines[1:]).strip()
|
| 278 |
+
if len(first) < 150 and not re.search(r"[.!?]$", first):
|
| 279 |
+
return "\n".join(lines[1:]).strip()
|
| 280 |
+
return text
|
| 281 |
+
|
| 282 |
+
@staticmethod
|
| 283 |
+
def _strip_metadata(text: str) -> str:
|
| 284 |
+
lines = text.split("\n")
|
| 285 |
+
cleaned = []
|
| 286 |
+
for line in lines:
|
| 287 |
+
clean = line.strip()
|
| 288 |
+
if re.match(r"^\s*[—\-] (Published|Updated|BBC News|Image|Copyright)", clean, re.IGNORECASE):
|
| 289 |
+
continue
|
| 290 |
+
cleaned.append(line)
|
| 291 |
+
return "\n".join(cleaned).strip()
|
| 292 |
+
|
| 293 |
+
def _classify_topics(self, article: Article, summary: str) -> list[str]:
|
| 294 |
+
if self._classifier:
|
| 295 |
+
try:
|
| 296 |
+
text = f"{article.title} {summary}" if summary else article.title
|
| 297 |
+
candidates = self.config.user_interests + ["other"]
|
| 298 |
+
result = self._classifier(text[:512], candidates)
|
| 299 |
+
return [
|
| 300 |
+
label for label, score in zip(result["labels"], result["scores"])
|
| 301 |
+
if score > 0.25
|
| 302 |
+
]
|
| 303 |
+
except Exception as exc:
|
| 304 |
+
logger.debug("Classifier failed: %s", exc)
|
| 305 |
+
|
| 306 |
+
return self._keyword_topic_match(article, summary)
|
| 307 |
+
|
| 308 |
+
DOMAIN_TOPICS: dict[str, str] = {
|
| 309 |
+
"krebsonsecurity.com": "cybersecurity",
|
| 310 |
+
"bleepingcomputer.com": "cybersecurity",
|
| 311 |
+
"theonion.com": "onion",
|
| 312 |
+
"ign.com": "gaming",
|
| 313 |
+
"eurogamer.net": "gaming",
|
| 314 |
+
"pcgamer.com": "gaming",
|
| 315 |
+
"rockpapershotgun.com": "gaming",
|
| 316 |
+
"kotaku.com": "gaming",
|
| 317 |
+
"gamespot.com": "gaming",
|
| 318 |
+
"arabnews.com": "arab_world",
|
| 319 |
+
"middleeasteye.net": "arab_world",
|
| 320 |
+
"thenationalnews.com": "arab_world",
|
| 321 |
+
"newarab.com": "arab_world",
|
| 322 |
+
"therecord.media": "cybersecurity",
|
| 323 |
+
"threatpost.com": "cybersecurity",
|
| 324 |
+
"thedailymash.co.uk": "funny",
|
| 325 |
+
"babylonbee.com": "onion",
|
| 326 |
+
"polygon.com": "gaming",
|
| 327 |
+
"variety.com": "movies",
|
| 328 |
+
"hollywoodreporter.com": "movies",
|
| 329 |
+
"deadline.com": "movies",
|
| 330 |
+
"screenrant.com": "movies",
|
| 331 |
+
"tunisiaonlinenews.com": "tunisia",
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
def _keyword_topic_match(self, article: Article, summary: str) -> list[str]:
|
| 335 |
+
text = f"{article.title} {summary}" if summary else article.title
|
| 336 |
+
found = []
|
| 337 |
+
for topic, patterns in TOPIC_MAP.items():
|
| 338 |
+
if any(re.search(p, text, re.IGNORECASE) for p in patterns):
|
| 339 |
+
found.append(topic)
|
| 340 |
+
domain = re.sub(r"^www\.", "", (article.source_domain or ""))
|
| 341 |
+
mapped = self.DOMAIN_TOPICS.get(domain)
|
| 342 |
+
# Only use domain fallback if keyword matching found nothing
|
| 343 |
+
if mapped and mapped not in found:
|
| 344 |
+
if not found:
|
| 345 |
+
found.append(mapped)
|
| 346 |
+
return found
|
| 347 |
+
|
| 348 |
+
def _assess_trustworthiness(self, article: Article) -> float:
|
| 349 |
+
score = 0.40
|
| 350 |
+
|
| 351 |
+
domain = article.source_domain or ""
|
| 352 |
+
clean_domain = re.sub(r"^www\.", "", domain)
|
| 353 |
+
score += RELIABLE_DOMAINS.get(clean_domain, 0.0)
|
| 354 |
+
score += UNRELIABLE_DOMAINS.get(clean_domain, 0.0)
|
| 355 |
+
|
| 356 |
+
text = article.text or ""
|
| 357 |
+
title = article.title or ""
|
| 358 |
+
if article.extraction_success is False:
|
| 359 |
+
score -= 0.15
|
| 360 |
+
elif len(text) > len(title) * 3:
|
| 361 |
+
score += 0.05
|
| 362 |
+
|
| 363 |
+
word_count = len(text.split())
|
| 364 |
+
if word_count > 200:
|
| 365 |
+
score += 0.10
|
| 366 |
+
elif word_count > 100:
|
| 367 |
+
score += 0.05
|
| 368 |
+
elif word_count > 50:
|
| 369 |
+
score += 0.02
|
| 370 |
+
|
| 371 |
+
factual_count = sum(1 for p in FACTUAL_KEYWORDS if re.search(p, text, re.IGNORECASE))
|
| 372 |
+
score += min(factual_count * 0.02, 0.08)
|
| 373 |
+
|
| 374 |
+
if any(re.search(p, title, re.IGNORECASE) for p in CLICKBAIT_PATTERNS):
|
| 375 |
+
score -= 0.20
|
| 376 |
+
|
| 377 |
+
opinion_count = sum(1 for p in OPINION_MARKERS if re.search(p, text, re.IGNORECASE))
|
| 378 |
+
score -= opinion_count * 0.05
|
| 379 |
+
|
| 380 |
+
return max(0.05, min(1.0, score))
|
| 381 |
+
|
| 382 |
+
@staticmethod
|
| 383 |
+
def _map_category(topics: list[str]) -> str:
|
| 384 |
+
priority = ["onion", "funny", "weird", "cybersecurity", "gaming",
|
| 385 |
+
"technology", "artificial intelligence", "health", "science",
|
| 386 |
+
"tunisia", "arab_world", "world", "politics", "immigration", "economy",
|
| 387 |
+
"energy", "education", "climate change", "space", "sports", "movies"]
|
| 388 |
+
topic_set = {t.lower() for t in topics}
|
| 389 |
+
for p in priority:
|
| 390 |
+
if p in topic_set:
|
| 391 |
+
mapped = TOPIC_TO_CATEGORY.get(p)
|
| 392 |
+
if mapped:
|
| 393 |
+
return mapped
|
| 394 |
+
return "General"
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def _detect_opinion(text: str) -> bool:
|
| 398 |
+
count = sum(1 for p in OPINION_MARKERS if re.search(p, text.lower()))
|
| 399 |
+
return count >= 3
|
| 400 |
+
|
| 401 |
+
@staticmethod
|
| 402 |
+
def _detect_political_leaning(text: str) -> str:
|
| 403 |
+
text_lower = text.lower()
|
| 404 |
+
left = sum(1 for k in LEFT_KEYWORDS if k in text_lower)
|
| 405 |
+
right = sum(1 for k in RIGHT_KEYWORDS if k in text_lower)
|
| 406 |
+
if left > right + 1:
|
| 407 |
+
return "left-leaning"
|
| 408 |
+
if right > left + 1:
|
| 409 |
+
return "right-leaning"
|
| 410 |
+
return "centrist"
|
src/extractor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
from .models import Article
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ArticleExtractor:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self._trafilatura = None
|
| 13 |
+
self._init_backend()
|
| 14 |
+
|
| 15 |
+
def _init_backend(self):
|
| 16 |
+
try:
|
| 17 |
+
import trafilatura
|
| 18 |
+
self._trafilatura = trafilatura
|
| 19 |
+
except ImportError:
|
| 20 |
+
logger.info("trafilatura not installed — using fallback extractor")
|
| 21 |
+
|
| 22 |
+
def extract(self, url: str) -> Optional[Article]:
|
| 23 |
+
domain = urlparse(url).netloc
|
| 24 |
+
try:
|
| 25 |
+
if self._trafilatura:
|
| 26 |
+
return self._extract_trafilatura(url, domain)
|
| 27 |
+
return self._extract_fallback(url, domain)
|
| 28 |
+
except Exception as exc:
|
| 29 |
+
logger.debug("Extraction failed for %s: %s", url, exc)
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
def _extract_trafilatura(self, url: str, domain: str) -> Optional[Article]:
|
| 33 |
+
downloaded = self._trafilatura.fetch_url(url)
|
| 34 |
+
if not downloaded:
|
| 35 |
+
return None
|
| 36 |
+
text = self._trafilatura.extract(downloaded)
|
| 37 |
+
if not text:
|
| 38 |
+
return None
|
| 39 |
+
title = self._extract_title_meta(downloaded) or ""
|
| 40 |
+
image = self._extract_og_image(downloaded)
|
| 41 |
+
return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
|
| 42 |
+
|
| 43 |
+
def _extract_fallback(self, url: str, domain: str) -> Optional[Article]:
|
| 44 |
+
resp = requests.get(url, headers={"User-Agent": "newsapp/1.0"}, timeout=15)
|
| 45 |
+
resp.raise_for_status()
|
| 46 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 47 |
+
|
| 48 |
+
title = ""
|
| 49 |
+
if soup.title:
|
| 50 |
+
title = soup.title.get_text(strip=True)
|
| 51 |
+
|
| 52 |
+
image = self._extract_og_image_soup(soup)
|
| 53 |
+
|
| 54 |
+
paragraphs = soup.find_all("p")
|
| 55 |
+
text = "\n\n".join(p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True))
|
| 56 |
+
if not text:
|
| 57 |
+
return None
|
| 58 |
+
return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _extract_title_meta(html: str) -> Optional[str]:
|
| 62 |
+
import html as html_mod
|
| 63 |
+
import re
|
| 64 |
+
m = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
| 65 |
+
return html_mod.unescape(m.group(1).strip()) if m else None
|
| 66 |
+
|
| 67 |
+
@staticmethod
|
| 68 |
+
def _extract_og_image(html: str) -> str:
|
| 69 |
+
import re
|
| 70 |
+
m = re.search(
|
| 71 |
+
r'<meta\s+[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']',
|
| 72 |
+
html, re.IGNORECASE,
|
| 73 |
+
)
|
| 74 |
+
if m:
|
| 75 |
+
return m.group(1)
|
| 76 |
+
m = re.search(
|
| 77 |
+
r'<meta\s+[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']',
|
| 78 |
+
html, re.IGNORECASE,
|
| 79 |
+
)
|
| 80 |
+
return m.group(1) if m else ""
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def _extract_og_image_soup(soup) -> str:
|
| 84 |
+
for prop in ("og:image", "twitter:image"):
|
| 85 |
+
tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
|
| 86 |
+
if tag and tag.get("content"):
|
| 87 |
+
return tag["content"]
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
import requests
|
| 92 |
+
from bs4 import BeautifulSoup
|
src/hn_scraper.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
from .models import RedditPost
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
API_BASE = "https://hacker-news.firebaseio.com/v0"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class HackerNewsScraper:
|
| 15 |
+
def __init__(self, config):
|
| 16 |
+
self.config = config
|
| 17 |
+
|
| 18 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 19 |
+
resp = requests.get(f"{API_BASE}/topstories.json", timeout=15)
|
| 20 |
+
resp.raise_for_status()
|
| 21 |
+
all_ids = resp.json()
|
| 22 |
+
|
| 23 |
+
limit = min(self.config.posts_per_subreddit * 3, 50)
|
| 24 |
+
posts = []
|
| 25 |
+
for story_id in all_ids[:limit]:
|
| 26 |
+
try:
|
| 27 |
+
detail = requests.get(f"{API_BASE}/item/{story_id}.json", timeout=10)
|
| 28 |
+
detail.raise_for_status()
|
| 29 |
+
data = detail.json()
|
| 30 |
+
if not data or data.get("type") != "story":
|
| 31 |
+
continue
|
| 32 |
+
url = data.get("url") or f"https://news.ycombinator.com/item?id={story_id}"
|
| 33 |
+
posts.append(RedditPost(
|
| 34 |
+
id=f"hn_{story_id}",
|
| 35 |
+
title=data.get("title", ""),
|
| 36 |
+
url=url,
|
| 37 |
+
subreddit="hackernews",
|
| 38 |
+
score=data.get("score", 0),
|
| 39 |
+
num_comments=data.get("descendants", 0),
|
| 40 |
+
source_domain=urlparse(url).netloc,
|
| 41 |
+
))
|
| 42 |
+
except Exception as exc:
|
| 43 |
+
logger.debug("Failed to fetch HN item %s: %s", story_id, exc)
|
| 44 |
+
|
| 45 |
+
posts.sort(key=lambda p: p.score, reverse=True)
|
| 46 |
+
posts = posts[: self.config.posts_per_subreddit]
|
| 47 |
+
logger.info("Fetched %d posts from Hacker News", len(posts))
|
| 48 |
+
return posts
|
src/mockdata.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
from .models import Analysis, Article, NewsItem, RedditPost
|
| 5 |
+
|
| 6 |
+
_MOCK_ARTICLES = [
|
| 7 |
+
{
|
| 8 |
+
"title": "New AI model achieves breakthrough in protein folding prediction",
|
| 9 |
+
"text": (
|
| 10 |
+
"Researchers at DeepMind and several universities have announced a major breakthrough "
|
| 11 |
+
"in protein folding prediction using a new deep learning architecture. The model, called "
|
| 12 |
+
"AlphaFold-Next, is able to predict protein structures with accuracy approaching "
|
| 13 |
+
"experimental methods. This advancement could accelerate drug discovery and our "
|
| 14 |
+
"understanding of diseases. The team trained the model on a dataset of over 100,000 "
|
| 15 |
+
"known protein structures and used a novel attention mechanism to capture long-range "
|
| 16 |
+
"interactions between amino acids. Early tests show the model generalises well to "
|
| 17 |
+
"previously unseen protein families."
|
| 18 |
+
),
|
| 19 |
+
"domain": "nature.com",
|
| 20 |
+
"subreddit": "science",
|
| 21 |
+
"score": 5420,
|
| 22 |
+
"comments": 342,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"title": "WHO declares new global health emergency as novel virus spreads across continents",
|
| 26 |
+
"text": (
|
| 27 |
+
"The World Health Organization has declared a Public Health Emergency of International "
|
| 28 |
+
"Concern as a novel respiratory virus continues to spread rapidly across multiple "
|
| 29 |
+
"continents. The virus, which emerged in Southeast Asia, has been detected in 15 "
|
| 30 |
+
"countries so far. Health officials are implementing containment measures including "
|
| 31 |
+
"travel restrictions and increased surveillance. The WHO is coordinating with national "
|
| 32 |
+
"health agencies to ensure a rapid response. Vaccines are expected to begin clinical "
|
| 33 |
+
"trials within six months, according to the Director-General."
|
| 34 |
+
),
|
| 35 |
+
"domain": "reuters.com",
|
| 36 |
+
"subreddit": "worldnews",
|
| 37 |
+
"score": 8210,
|
| 38 |
+
"comments": 2801,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"title": "New study links ultra-processed foods to increased cancer risk",
|
| 42 |
+
"text": (
|
| 43 |
+
"A comprehensive study published in The Lancet has found a significant correlation "
|
| 44 |
+
"between consumption of ultra-processed foods and increased risk of developing certain "
|
| 45 |
+
"types of cancer. The study followed over 200,000 participants for 15 years and "
|
| 46 |
+
"controlled for lifestyle factors such as smoking and exercise. Researchers found that "
|
| 47 |
+
"participants who consumed the highest levels of ultra-processed foods had a 23% higher "
|
| 48 |
+
"risk of developing colorectal cancer. The findings add to growing evidence that dietary "
|
| 49 |
+
"patterns play a crucial role in cancer prevention."
|
| 50 |
+
),
|
| 51 |
+
"domain": "bbc.com",
|
| 52 |
+
"subreddit": "health",
|
| 53 |
+
"score": 3890,
|
| 54 |
+
"comments": 567,
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"title": "SpaceX successfully launches satellite constellation for global internet coverage",
|
| 58 |
+
"text": (
|
| 59 |
+
"SpaceX has successfully launched another batch of 60 Starlink satellites, bringing the "
|
| 60 |
+
"total constellation size to over 5,000. The Falcon 9 rocket lifted off from Cape "
|
| 61 |
+
"Canaveral and successfully deployed the satellites in low Earth orbit. This expansion "
|
| 62 |
+
"will bring high-speed internet access to previously unserved rural areas across the "
|
| 63 |
+
"globe. The company plans to increase the constellation to 12,000 satellites within "
|
| 64 |
+
"the next three years, with initial tests showing latency as low as 20 milliseconds."
|
| 65 |
+
),
|
| 66 |
+
"domain": "reuters.com",
|
| 67 |
+
"subreddit": "technology",
|
| 68 |
+
"score": 4560,
|
| 69 |
+
"comments": 890,
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"title": "Federal Reserve signals interest rate cut as inflation continues to cool",
|
| 73 |
+
"text": (
|
| 74 |
+
"The Federal Reserve has signalled it may cut interest rates at its next meeting as "
|
| 75 |
+
"inflation continues to trend downward toward the 2% target. Recent economic data "
|
| 76 |
+
"shows consumer prices rose only 2.3% year-over-year, down from a peak of 9.1% two "
|
| 77 |
+
"years ago. Fed Chair Jerome Powell stated that while progress has been made, the "
|
| 78 |
+
"committee would remain data-dependent. Markets responded positively, with the S&P "
|
| 79 |
+
"500 rising 1.2% on the news. Economists expect a quarter-point cut in September."
|
| 80 |
+
),
|
| 81 |
+
"domain": "wsj.com",
|
| 82 |
+
"subreddit": "economy",
|
| 83 |
+
"score": 3200,
|
| 84 |
+
"comments": 1200,
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"title": "Global climate summit reaches historic agreement on fossil fuel phase-out",
|
| 88 |
+
"text": (
|
| 89 |
+
"Nearly 200 nations have reached a landmark agreement to phase out fossil fuels at the "
|
| 90 |
+
"UN Climate Summit in Dubai. The agreement sets a timeline for reducing coal, oil, and "
|
| 91 |
+
"gas production, with developed nations committing to faster reductions. Developing "
|
| 92 |
+
"countries will receive financial support through a new climate fund worth $100 billion "
|
| 93 |
+
"annually. Environmental groups have cautiously welcomed the deal while noting that "
|
| 94 |
+
"the timeline may need to accelerate to meet Paris Agreement targets. The agreement "
|
| 95 |
+
"marks the first time all nations have explicitly committed to transitioning away from "
|
| 96 |
+
"fossil fuels."
|
| 97 |
+
),
|
| 98 |
+
"domain": "theguardian.com",
|
| 99 |
+
"subreddit": "worldnews",
|
| 100 |
+
"score": 9500,
|
| 101 |
+
"comments": 3400,
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"title": "Cybersecurity researchers discover zero-day exploit affecting billions of devices",
|
| 105 |
+
"text": (
|
| 106 |
+
"Security researchers have discovered a critical vulnerability in a widely-used "
|
| 107 |
+
"networking library that affects an estimated 3 billion devices worldwide. The "
|
| 108 |
+
"zero-day exploit, dubbed 'PacketStorm', allows remote code execution without user "
|
| 109 |
+
"interaction. Major technology companies including Google, Apple, and Microsoft have "
|
| 110 |
+
"released emergency patches. Users are strongly advised to update their devices "
|
| 111 |
+
"immediately. The vulnerability has been present in the codebase for over a decade "
|
| 112 |
+
"and was discovered during a routine security audit."
|
| 113 |
+
),
|
| 114 |
+
"domain": "reuters.com",
|
| 115 |
+
"subreddit": "technology",
|
| 116 |
+
"score": 6700,
|
| 117 |
+
"comments": 1500,
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"title": "Unprecedented heatwave breaks temperature records across Europe",
|
| 121 |
+
"text": (
|
| 122 |
+
"An unprecedented heatwave is sweeping across Europe, with temperatures exceeding "
|
| 123 |
+
"45°C in several countries. Multiple heat records have been broken, including the "
|
| 124 |
+
"all-time high for the United Kingdom at 42.3°C. Authorities have issued red alerts "
|
| 125 |
+
"and are urging residents to stay indoors. The extreme weather has been linked to "
|
| 126 |
+
"climate change by leading meteorological agencies. Hospitals are reporting increased "
|
| 127 |
+
"admissions for heat-related illnesses, and transport networks have been disrupted "
|
| 128 |
+
"due to heat-damaged infrastructure."
|
| 129 |
+
),
|
| 130 |
+
"domain": "bbc.com",
|
| 131 |
+
"subreddit": "worldnews",
|
| 132 |
+
"score": 7800,
|
| 133 |
+
"comments": 2100,
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"title": "New CRISPR therapy shows promising results in clinical trial for sickle cell disease",
|
| 137 |
+
"text": (
|
| 138 |
+
"A groundbreaking CRISPR-based gene therapy has shown remarkable results in a Phase 3 "
|
| 139 |
+
"clinical trial for sickle cell disease. Out of 45 patients, 42 showed complete "
|
| 140 |
+
"remission of symptoms 12 months after treatment. The therapy, developed by Vertex "
|
| 141 |
+
"Pharmaceuticals, uses CRISPR-Cas9 to edit the patient's own stem cells, correcting "
|
| 142 |
+
"the genetic mutation responsible for the disease. The FDA has granted breakthrough "
|
| 143 |
+
"therapy designation, potentially accelerating approval. This marks one of the first "
|
| 144 |
+
"successful CRISPR-based treatments for a genetic blood disorder."
|
| 145 |
+
),
|
| 146 |
+
"domain": "nature.com",
|
| 147 |
+
"subreddit": "science",
|
| 148 |
+
"score": 5100,
|
| 149 |
+
"comments": 450,
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"title": "AI regulation bill passes Senate with bipartisan support",
|
| 153 |
+
"text": (
|
| 154 |
+
"The US Senate has passed a landmark artificial intelligence regulation bill with "
|
| 155 |
+
"significant bipartisan support. The legislation requires AI companies to conduct "
|
| 156 |
+
"safety testing before releasing powerful models, establish transparency requirements, "
|
| 157 |
+
"and create a new federal agency to oversee AI development. The bill was co-sponsored "
|
| 158 |
+
"by senators from both parties and represents one of the most comprehensive AI "
|
| 159 |
+
"governance frameworks in the world. Tech companies have expressed mixed reactions, "
|
| 160 |
+
"with some supporting the clarity while others worry about innovation impact."
|
| 161 |
+
),
|
| 162 |
+
"domain": "nytimes.com",
|
| 163 |
+
"subreddit": "politics",
|
| 164 |
+
"score": 4300,
|
| 165 |
+
"comments": 1800,
|
| 166 |
+
},
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def generate_demo_items() -> list[NewsItem]:
|
| 171 |
+
items = []
|
| 172 |
+
for art in _MOCK_ARTICLES:
|
| 173 |
+
post = RedditPost(
|
| 174 |
+
id=f"mock_{random.randint(100000, 999999)}",
|
| 175 |
+
title=art["title"],
|
| 176 |
+
url=f"https://{art['domain']}/article/{random.randint(1000, 9999)}",
|
| 177 |
+
subreddit=art["subreddit"],
|
| 178 |
+
score=art["score"],
|
| 179 |
+
num_comments=art["comments"],
|
| 180 |
+
source_domain=art["domain"],
|
| 181 |
+
)
|
| 182 |
+
article = Article(
|
| 183 |
+
url=post.url,
|
| 184 |
+
title=art["title"],
|
| 185 |
+
text=art["text"],
|
| 186 |
+
source_domain=art["domain"],
|
| 187 |
+
)
|
| 188 |
+
items.append(NewsItem(post=post, article=article))
|
| 189 |
+
return items
|
src/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class RedditPost:
|
| 7 |
+
id: str
|
| 8 |
+
title: str
|
| 9 |
+
url: str
|
| 10 |
+
subreddit: str
|
| 11 |
+
score: int
|
| 12 |
+
num_comments: int
|
| 13 |
+
source_domain: str = ""
|
| 14 |
+
image_url: str = ""
|
| 15 |
+
published: str = ""
|
| 16 |
+
published_iso: str = ""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Article:
|
| 21 |
+
url: str
|
| 22 |
+
title: str
|
| 23 |
+
text: str
|
| 24 |
+
source_domain: str
|
| 25 |
+
extraction_success: bool = True
|
| 26 |
+
image_url: str = ""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class Analysis:
|
| 31 |
+
summary: str
|
| 32 |
+
topics: list[str]
|
| 33 |
+
trustworthiness_score: float
|
| 34 |
+
is_opinion: bool
|
| 35 |
+
political_leaning: str = "centrist"
|
| 36 |
+
category: str = "General"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class NewsItem:
|
| 41 |
+
post: RedditPost
|
| 42 |
+
article: Optional[Article] = None
|
| 43 |
+
analysis: Optional[Analysis] = None
|
| 44 |
+
final_score: float = 0.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class NewsCluster:
|
| 49 |
+
topic: str
|
| 50 |
+
articles: list[NewsItem]
|
| 51 |
+
total_coverage: int
|
| 52 |
+
avg_trustworthiness: float
|
| 53 |
+
avg_popularity: float
|
| 54 |
+
top_post_url: str
|
| 55 |
+
final_score: float = 0.0
|
| 56 |
+
image_url: str = ""
|
src/presenter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import defaultdict
|
| 2 |
+
|
| 3 |
+
from .models import NewsCluster
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class NewsPresenter:
|
| 7 |
+
CATEGORIES = ["Geopolitical", "World Health", "Tech", "Cybersecurity", "Funny/Weird", "Gaming", "Movies", "Arab World", "Tunisia"]
|
| 8 |
+
|
| 9 |
+
@staticmethod
|
| 10 |
+
def display(clusters: list[NewsCluster], top_n: int = 10):
|
| 11 |
+
if not clusters:
|
| 12 |
+
print("\n No news stories matched your interests this round.")
|
| 13 |
+
return
|
| 14 |
+
|
| 15 |
+
by_cat: dict[str, list[NewsCluster]] = defaultdict(list)
|
| 16 |
+
for c in clusters:
|
| 17 |
+
cat = "General"
|
| 18 |
+
if c.articles and c.articles[0].analysis:
|
| 19 |
+
ca = c.articles[0].analysis.category
|
| 20 |
+
cat = ca if ca in NewsPresenter.CATEGORIES else "General"
|
| 21 |
+
by_cat[cat].append(c)
|
| 22 |
+
|
| 23 |
+
print("╔" + "═" * 78 + "╗")
|
| 24 |
+
print("║ 📰 NEWS DIGEST — Top Stories ║".center(80))
|
| 25 |
+
print("╚" + "═" * 78 + "╝")
|
| 26 |
+
|
| 27 |
+
for cat in NewsPresenter.CATEGORIES:
|
| 28 |
+
items = by_cat.get(cat, [])
|
| 29 |
+
items.sort(key=lambda x: (x.articles[0].post.published_iso or "", x.final_score), reverse=True)
|
| 30 |
+
items = items[:top_n]
|
| 31 |
+
if not items:
|
| 32 |
+
continue
|
| 33 |
+
print(f"\n ══ {cat} ({len(items)}) ══\n")
|
| 34 |
+
for i, cluster in enumerate(items, 1):
|
| 35 |
+
print(f" #{i:<2} [{cluster.topic:<30}] "
|
| 36 |
+
f"Score: {cluster.final_score:.2f} "
|
| 37 |
+
f"Trust: {cluster.avg_trustworthiness:.0%}")
|
| 38 |
+
item = cluster.articles[0]
|
| 39 |
+
title = item.post.title[:72] + "…" if len(item.post.title) > 72 else item.post.title
|
| 40 |
+
print(f" {title}")
|
| 41 |
+
if item.post.published:
|
| 42 |
+
print(f" 📅 {item.post.published}")
|
| 43 |
+
if item.analysis and item.analysis.summary:
|
| 44 |
+
short = item.analysis.summary[:72] + "…" if len(item.analysis.summary) > 72 else item.analysis.summary
|
| 45 |
+
print(f" → {short}")
|
| 46 |
+
print()
|
| 47 |
+
|
| 48 |
+
print("▔" * 80)
|
src/rss_feed_scraper.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import time
|
| 3 |
+
from email.utils import parsedate_to_datetime
|
| 4 |
+
from urllib.parse import urlparse, urlencode, parse_qs
|
| 5 |
+
|
| 6 |
+
import feedparser
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from .models import RedditPost
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
TRACKING_PARAMS = {"at_medium", "at_campaign", "ref", "utm_source", "utm_medium", "utm_campaign"}
|
| 14 |
+
|
| 15 |
+
DEFAULT_FEEDS = [
|
| 16 |
+
# Geopolitical
|
| 17 |
+
"https://feeds.bbci.co.uk/news/world/rss.xml",
|
| 18 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
| 19 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml",
|
| 20 |
+
"https://feeds.npr.org/1001/rss.xml",
|
| 21 |
+
"https://www.aljazeera.com/xml/rss/all.xml",
|
| 22 |
+
"https://www.theguardian.com/world/rss",
|
| 23 |
+
# World Health
|
| 24 |
+
"https://feeds.bbci.co.uk/news/health/rss.xml",
|
| 25 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Science.xml",
|
| 26 |
+
"https://www.statnews.com/feed/",
|
| 27 |
+
"https://www.sciencedaily.com/rss/all.xml",
|
| 28 |
+
# Tech
|
| 29 |
+
"https://feeds.bbci.co.uk/news/technology/rss.xml",
|
| 30 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Technology.xml",
|
| 31 |
+
"https://techcrunch.com/feed/",
|
| 32 |
+
"https://www.wired.com/feed/rss",
|
| 33 |
+
"https://www.theverge.com/rss/index.xml",
|
| 34 |
+
"https://arstechnica.com/feed/",
|
| 35 |
+
# Cybersecurity
|
| 36 |
+
"https://feeds.feedburner.com/TheHackerNews",
|
| 37 |
+
"https://krebsonsecurity.com/feed/",
|
| 38 |
+
"https://www.bleepingcomputer.com/feed/",
|
| 39 |
+
"https://threatpost.com/feed/",
|
| 40 |
+
"https://therecord.media/feed/",
|
| 41 |
+
# Funny / Weird
|
| 42 |
+
"https://www.theonion.com/rss",
|
| 43 |
+
"https://www.reddit.com/r/nottheonion/.rss",
|
| 44 |
+
"https://www.thedailymash.co.uk/feed",
|
| 45 |
+
"https://babylonbee.com/feed",
|
| 46 |
+
# Gaming
|
| 47 |
+
"https://feeds.ign.com/ign/all",
|
| 48 |
+
"https://www.eurogamer.net/feed",
|
| 49 |
+
"https://www.pcgamer.com/rss/",
|
| 50 |
+
"https://www.kotaku.com/rss",
|
| 51 |
+
"https://www.gamespot.com/feeds/news/",
|
| 52 |
+
"https://www.polygon.com/rss/index.xml",
|
| 53 |
+
# Movies
|
| 54 |
+
"https://variety.com/feed/",
|
| 55 |
+
"https://www.hollywoodreporter.com/feed/",
|
| 56 |
+
"https://deadline.com/feed/",
|
| 57 |
+
"https://screenrant.com/feed/",
|
| 58 |
+
# Arab World
|
| 59 |
+
"https://www.arabnews.com/rss.xml",
|
| 60 |
+
"https://www.middleeasteye.net/rss",
|
| 61 |
+
"https://www.newarab.com/rss.xml",
|
| 62 |
+
"https://www.france24.com/en/middle-east/rss",
|
| 63 |
+
# Tunisia
|
| 64 |
+
"https://www.tunisiaonlinenews.com/feed/",
|
| 65 |
+
"https://northafricapost.com/feed/",
|
| 66 |
+
"https://www.africanews.com/feed/",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class RSSFeedScraper:
|
| 71 |
+
def __init__(self, config):
|
| 72 |
+
self.config = config
|
| 73 |
+
self.feeds = getattr(config, "rss_feeds", DEFAULT_FEEDS)
|
| 74 |
+
self.session = requests.Session()
|
| 75 |
+
self.session.headers.update({
|
| 76 |
+
"User-Agent": "Mozilla/5.0 (compatible; newsapp/1.0)",
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 80 |
+
seen_titles = set()
|
| 81 |
+
posts = []
|
| 82 |
+
for url in self.feeds:
|
| 83 |
+
logger.info("Fetching RSS: %s", url)
|
| 84 |
+
try:
|
| 85 |
+
if "reddit.com" in url:
|
| 86 |
+
time.sleep(2.5)
|
| 87 |
+
resp = self.session.get(url, timeout=15)
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
feed = feedparser.parse(resp.content)
|
| 90 |
+
for entry in feed.entries[: self.config.posts_per_subreddit]:
|
| 91 |
+
post = self._entry_to_post(entry, url)
|
| 92 |
+
if post and post.title not in seen_titles:
|
| 93 |
+
seen_titles.add(post.title)
|
| 94 |
+
posts.append(post)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.warning("Failed RSS %s: %s", url, exc)
|
| 97 |
+
time.sleep(1.0)
|
| 98 |
+
return posts
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def _clean_url(url: str) -> str:
|
| 102 |
+
parsed = urlparse(url)
|
| 103 |
+
if not parsed.query:
|
| 104 |
+
return url
|
| 105 |
+
params = parse_qs(parsed.query)
|
| 106 |
+
clean = {k: v[0] for k, v in params.items() if k.lower() not in TRACKING_PARAMS}
|
| 107 |
+
if not clean:
|
| 108 |
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
| 109 |
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(clean)}"
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def _extract_image(entry) -> str:
|
| 113 |
+
for key in ("media_content", "media_thumbnail"):
|
| 114 |
+
items = entry.get(key) or []
|
| 115 |
+
for item in items:
|
| 116 |
+
url = item.get("url", "")
|
| 117 |
+
if url:
|
| 118 |
+
return url
|
| 119 |
+
for link in entry.get("links", []):
|
| 120 |
+
if link.get("rel") == "enclosure" and "image" in link.get("type", ""):
|
| 121 |
+
return link.get("href", "")
|
| 122 |
+
return ""
|
| 123 |
+
|
| 124 |
+
def _entry_to_post(self, entry, feed_url: str) -> RedditPost | None:
|
| 125 |
+
title = entry.get("title", "")
|
| 126 |
+
if not title:
|
| 127 |
+
return None
|
| 128 |
+
link = self._clean_url(entry.get("link", ""))
|
| 129 |
+
domain = urlparse(link).netloc or urlparse(feed_url).netloc
|
| 130 |
+
image_url = self._extract_image(entry)
|
| 131 |
+
published, published_iso = self._format_date(entry)
|
| 132 |
+
return RedditPost(
|
| 133 |
+
id=entry.get("id", entry.get("guid", link)),
|
| 134 |
+
title=title,
|
| 135 |
+
url=link,
|
| 136 |
+
subreddit=domain,
|
| 137 |
+
score=0, num_comments=0,
|
| 138 |
+
source_domain=domain,
|
| 139 |
+
image_url=image_url,
|
| 140 |
+
published=published,
|
| 141 |
+
published_iso=published_iso,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def _format_date(entry) -> tuple[str, str]:
|
| 146 |
+
raw = entry.get("published") or entry.get("updated") or ""
|
| 147 |
+
if not raw:
|
| 148 |
+
return ("", "")
|
| 149 |
+
try:
|
| 150 |
+
dt = parsedate_to_datetime(raw)
|
| 151 |
+
display = dt.strftime("%d %b %Y")
|
| 152 |
+
iso = dt.strftime("%Y-%m-%d")
|
| 153 |
+
return (display, iso)
|
| 154 |
+
except Exception:
|
| 155 |
+
if len(raw) >= 10 and raw[4] == "-" and raw[7] == "-":
|
| 156 |
+
iso = raw[:10]
|
| 157 |
+
from datetime import datetime
|
| 158 |
+
try:
|
| 159 |
+
dt = datetime.strptime(iso, "%Y-%m-%d")
|
| 160 |
+
display = dt.strftime("%d %b %Y")
|
| 161 |
+
except Exception:
|
| 162 |
+
display = iso
|
| 163 |
+
return (display, iso)
|
| 164 |
+
return (raw[:16], "")
|
src/scraper.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from urllib.parse import urlparse
|
| 3 |
+
|
| 4 |
+
from .models import RedditPost
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RedditScraper:
|
| 10 |
+
def __init__(self, config):
|
| 11 |
+
self.config = config
|
| 12 |
+
self._praw = None
|
| 13 |
+
self._init_praw()
|
| 14 |
+
|
| 15 |
+
def _init_praw(self):
|
| 16 |
+
cid = self.config.reddit_client_id
|
| 17 |
+
secret = self.config.reddit_client_secret
|
| 18 |
+
if not cid or not secret:
|
| 19 |
+
raise ValueError(
|
| 20 |
+
"Reddit API credentials not found.\n"
|
| 21 |
+
" Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env, or\n"
|
| 22 |
+
" use python main.py --demo to run with sample data."
|
| 23 |
+
)
|
| 24 |
+
try:
|
| 25 |
+
import praw
|
| 26 |
+
self._praw = praw.Reddit(
|
| 27 |
+
client_id=cid,
|
| 28 |
+
client_secret=secret,
|
| 29 |
+
user_agent=self.config.reddit_user_agent,
|
| 30 |
+
)
|
| 31 |
+
logger.info("Reddit API initialised via PRAW")
|
| 32 |
+
except ImportError:
|
| 33 |
+
raise ImportError("praw is required. Install with: pip install praw")
|
| 34 |
+
|
| 35 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 36 |
+
seen = set()
|
| 37 |
+
posts = []
|
| 38 |
+
for sub in self.config.news_subreddits:
|
| 39 |
+
logger.info("Fetching r/%s ...", sub)
|
| 40 |
+
try:
|
| 41 |
+
batch = self._fetch_subreddit(sub)
|
| 42 |
+
for p in batch:
|
| 43 |
+
if p.id not in seen:
|
| 44 |
+
seen.add(p.id)
|
| 45 |
+
posts.append(p)
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
logger.error("Failed to fetch r/%s: %s", sub, exc)
|
| 48 |
+
return posts
|
| 49 |
+
|
| 50 |
+
def _fetch_subreddit(self, subreddit: str) -> list[RedditPost]:
|
| 51 |
+
results = []
|
| 52 |
+
sub = self._praw.subreddit(subreddit)
|
| 53 |
+
for submission in sub.hot(limit=self.config.posts_per_subreddit):
|
| 54 |
+
if submission.is_self:
|
| 55 |
+
continue
|
| 56 |
+
results.append(RedditPost(
|
| 57 |
+
id=submission.id,
|
| 58 |
+
title=submission.title,
|
| 59 |
+
url=submission.url,
|
| 60 |
+
subreddit=subreddit.lower(),
|
| 61 |
+
score=submission.score,
|
| 62 |
+
num_comments=submission.num_comments,
|
| 63 |
+
source_domain=urlparse(submission.url).netloc,
|
| 64 |
+
))
|
| 65 |
+
return results
|
src/src/__init__.py
ADDED
|
File without changes
|
src/src/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (112 Bytes). View file
|
|
|
src/src/__pycache__/aggregator.cpython-312.pyc
ADDED
|
Binary file (8.64 kB). View file
|
|
|
src/src/__pycache__/analyzer.cpython-312.pyc
ADDED
|
Binary file (21.8 kB). View file
|
|
|
src/src/__pycache__/extractor.cpython-312.pyc
ADDED
|
Binary file (5.58 kB). View file
|
|
|
src/src/__pycache__/hn_scraper.cpython-312.pyc
ADDED
|
Binary file (3.18 kB). View file
|
|
|
src/src/__pycache__/mockdata.cpython-312.pyc
ADDED
|
Binary file (8.43 kB). View file
|
|
|
src/src/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (2.4 kB). View file
|
|
|
src/src/__pycache__/presenter.cpython-312.pyc
ADDED
|
Binary file (4.32 kB). View file
|
|
|
src/src/__pycache__/rss_feed_scraper.cpython-312.pyc
ADDED
|
Binary file (8.06 kB). View file
|
|
|
src/src/__pycache__/rss_scraper.cpython-312.pyc
ADDED
|
Binary file (5.4 kB). View file
|
|
|
src/src/__pycache__/scraper.cpython-312.pyc
ADDED
|
Binary file (5.33 kB). View file
|
|
|
src/src/aggregator.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from .models import NewsCluster, NewsItem
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class NewsAggregator:
|
| 11 |
+
def __init__(self, config):
|
| 12 |
+
self.config = config
|
| 13 |
+
self._encoder = None
|
| 14 |
+
self._setup_encoder()
|
| 15 |
+
|
| 16 |
+
def _setup_encoder(self):
|
| 17 |
+
if not self.config.use_local_models:
|
| 18 |
+
logger.info("Local models disabled — using keyword similarity")
|
| 19 |
+
return
|
| 20 |
+
try:
|
| 21 |
+
from sentence_transformers import SentenceTransformer
|
| 22 |
+
model_name = f"sentence-transformers/{self.config.embedding_model}"
|
| 23 |
+
logger.info("Loading embedding model: %s ...", model_name)
|
| 24 |
+
self._encoder = SentenceTransformer(model_name)
|
| 25 |
+
except ImportError:
|
| 26 |
+
logger.warning("sentence-transformers not available — using keyword fallback")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
logger.warning("Embedding model failed: %s", exc)
|
| 29 |
+
|
| 30 |
+
def compute_similarity(self, a: str, b: str) -> float:
|
| 31 |
+
if self._encoder:
|
| 32 |
+
emb_a = self._encoder.encode(a, normalize_embeddings=True)
|
| 33 |
+
emb_b = self._encoder.encode(b, normalize_embeddings=True)
|
| 34 |
+
return float(emb_a @ emb_b)
|
| 35 |
+
return self._keyword_overlap(a, b)
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
def _keyword_overlap(a: str, b: str) -> float:
|
| 39 |
+
words_a = set(a.lower().split())
|
| 40 |
+
words_b = set(b.lower().split())
|
| 41 |
+
if not words_a or not words_b:
|
| 42 |
+
return 0.0
|
| 43 |
+
common = words_a & words_b
|
| 44 |
+
return len(common) / max(len(words_a), len(words_b))
|
| 45 |
+
|
| 46 |
+
def cluster_news(self, items: list[NewsItem]) -> list[NewsCluster]:
|
| 47 |
+
clusters: list[list[NewsItem]] = []
|
| 48 |
+
|
| 49 |
+
for item in items:
|
| 50 |
+
if not item.analysis or not item.article:
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
text = f"{item.post.title} {item.analysis.summary}"
|
| 54 |
+
placed = False
|
| 55 |
+
|
| 56 |
+
for cluster in clusters:
|
| 57 |
+
rep = cluster[0]
|
| 58 |
+
if not rep.analysis:
|
| 59 |
+
continue
|
| 60 |
+
rep_text = f"{rep.post.title} {rep.analysis.summary}"
|
| 61 |
+
if self.compute_similarity(text, rep_text) >= self.config.similarity_threshold:
|
| 62 |
+
cluster.append(item)
|
| 63 |
+
placed = True
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
if not placed:
|
| 67 |
+
clusters.append([item])
|
| 68 |
+
|
| 69 |
+
return self._rank_clusters(clusters)
|
| 70 |
+
|
| 71 |
+
def _rank_clusters(self, raw: list[list[NewsItem]]) -> list[NewsCluster]:
|
| 72 |
+
scored = []
|
| 73 |
+
for group in raw:
|
| 74 |
+
if not group:
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
topic = self._main_topic(group)
|
| 78 |
+
|
| 79 |
+
# Highest scoring post represents the cluster
|
| 80 |
+
best = max(group, key=lambda x: x.post.score)
|
| 81 |
+
|
| 82 |
+
avg_trust = sum(
|
| 83 |
+
it.analysis.trustworthiness_score for it in group if it.analysis
|
| 84 |
+
) / max(len(group), 1)
|
| 85 |
+
|
| 86 |
+
avg_pop = sum(it.post.score for it in group) / max(len(group), 1)
|
| 87 |
+
|
| 88 |
+
# Pick the first non-empty image
|
| 89 |
+
image_url = ""
|
| 90 |
+
for it in group:
|
| 91 |
+
src = it.article.image_url if it.article else ""
|
| 92 |
+
if src:
|
| 93 |
+
image_url = src
|
| 94 |
+
break
|
| 95 |
+
if it.post.image_url:
|
| 96 |
+
image_url = it.post.image_url
|
| 97 |
+
break
|
| 98 |
+
|
| 99 |
+
cluster_score = self._cluster_score(group, avg_trust)
|
| 100 |
+
|
| 101 |
+
cluster = NewsCluster(
|
| 102 |
+
topic=topic,
|
| 103 |
+
articles=group,
|
| 104 |
+
total_coverage=len(group),
|
| 105 |
+
avg_trustworthiness=avg_trust,
|
| 106 |
+
avg_popularity=avg_pop,
|
| 107 |
+
top_post_url=best.post.url,
|
| 108 |
+
final_score=cluster_score,
|
| 109 |
+
image_url=image_url,
|
| 110 |
+
)
|
| 111 |
+
scored.append(cluster)
|
| 112 |
+
|
| 113 |
+
return sorted(scored, key=lambda c: c.final_score, reverse=True)
|
| 114 |
+
|
| 115 |
+
def _cluster_score(self, group: list[NewsItem], avg_trust: float) -> float:
|
| 116 |
+
scores = []
|
| 117 |
+
for item in group:
|
| 118 |
+
s = avg_trust * 0.50
|
| 119 |
+
|
| 120 |
+
# Content quality: longer articles score higher
|
| 121 |
+
if item.article and item.article.text:
|
| 122 |
+
title_len = len(item.article.title or "")
|
| 123 |
+
text_len = len(item.article.text)
|
| 124 |
+
if text_len > title_len * 3:
|
| 125 |
+
s += 0.15
|
| 126 |
+
elif text_len > title_len * 1.5:
|
| 127 |
+
s += 0.08
|
| 128 |
+
|
| 129 |
+
# Successfully extracted vs title-only
|
| 130 |
+
if item.article and item.article.extraction_success:
|
| 131 |
+
s += 0.10
|
| 132 |
+
else:
|
| 133 |
+
s -= 0.10
|
| 134 |
+
|
| 135 |
+
# More topics = richer article
|
| 136 |
+
if item.analysis and item.analysis.topics:
|
| 137 |
+
s += min(len(item.analysis.topics) * 0.06, 0.18)
|
| 138 |
+
|
| 139 |
+
# Having a category means we actually understood it
|
| 140 |
+
if item.analysis and item.analysis.category != "General":
|
| 141 |
+
s += 0.05
|
| 142 |
+
|
| 143 |
+
scores.append(max(0.05, min(1.0, s)))
|
| 144 |
+
|
| 145 |
+
return sum(scores) / max(len(scores), 1)
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _main_topic(cluster: list[NewsItem]) -> str:
|
| 149 |
+
counter: Counter[str] = Counter()
|
| 150 |
+
for item in cluster:
|
| 151 |
+
if item.analysis:
|
| 152 |
+
for t in item.analysis.topics:
|
| 153 |
+
counter[t] += 1
|
| 154 |
+
if counter:
|
| 155 |
+
return counter.most_common(1)[0][0]
|
| 156 |
+
return (cluster[0].post.title or "")[:60]
|
src/src/analyzer.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import html
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from .models import Analysis, Article
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
RELIABLE_DOMAINS = {
|
| 11 |
+
"reuters.com": 0.20, "apnews.com": 0.20, "bbc.com": 0.15,
|
| 12 |
+
"bbc.co.uk": 0.15, "npr.org": 0.12, "wsj.com": 0.12,
|
| 13 |
+
"economist.com": 0.15, "nature.com": 0.20, "science.org": 0.18,
|
| 14 |
+
"sciencedaily.com": 0.14, "theguardian.com": 0.08, "nytimes.com": 0.10,
|
| 15 |
+
"washingtonpost.com": 0.10, "ft.com": 0.14, "bloomberg.com": 0.12,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
UNRELIABLE_DOMAINS = {
|
| 19 |
+
"infowars.com": -0.30, "breitbart.com": -0.20, "dailymail.co.uk": -0.12,
|
| 20 |
+
"theonion.com": -0.25, "naturalnews.com": -0.30, "zerohedge.com": -0.15,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
CLICKBAIT_PATTERNS = [
|
| 24 |
+
r"you won'?t believe", r"shocked?", r"gobsmacked",
|
| 25 |
+
r"this is what happens", r"number \d+ will",
|
| 26 |
+
r"here'?s why", r"what happens next",
|
| 27 |
+
r"blown away", r"mind.?blowing",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
OPINION_MARKERS = [
|
| 31 |
+
r"\bi think\b", r"\bin my opinion\b", r"\bpersonally\b",
|
| 32 |
+
r"\bi believe\b", r"\bclearly\b", r"\bobviously\b",
|
| 33 |
+
r"\bin my view\b", r"\bit seems\b", r"\bi feel\b",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
LEFT_KEYWORDS = ["progressive", "equality", "social justice", "climate crisis",
|
| 37 |
+
"marginalized", "systemic", "privilege", "inequality"]
|
| 38 |
+
RIGHT_KEYWORDS = ["deregulation", "tax cuts", "free market", "traditional",
|
| 39 |
+
"sovereignty", "patriot", "heritage", "small government"]
|
| 40 |
+
|
| 41 |
+
TOPIC_MAP: dict[str, list[str]] = {
|
| 42 |
+
"artificial intelligence": [r"\bai\b", r"\bartificial intelligence\b",
|
| 43 |
+
r"\bmachine learning\b", r"\bgpt\b", r"\bllm\b",
|
| 44 |
+
r"\bneural network", r"\bdeep learning\b"],
|
| 45 |
+
"climate change": [r"\bclimate\b", r"\bglobal warming\b", r"\bemissions\b",
|
| 46 |
+
r"\bcarbon\b", r"\brenewable\b", r"\bsolar\b", r"\bwind turbine",
|
| 47 |
+
r"\bheatwave\b", r"\bextreme weather\b", r"\bheat wave\b"],
|
| 48 |
+
"health": [r"\bhealth\b", r"\bcovid\b", r"\bvaccine\b", r"\bdisease\b",
|
| 49 |
+
r"\bhospital\b", r"\bmedical\b", r"\bcancer\b", r"\bdrug\b",
|
| 50 |
+
r"\bod\b", r"\bpandemic\b", r"\bpatient\b", r"\bsurgery\b",
|
| 51 |
+
r"\bdoctor\b", r"\bnurse\b", r"\btreatment\b", r"\btherapy\b",
|
| 52 |
+
r"\bdementia\b", r"\bdiabetes\b", r"\bobesity\b", r"\bmental health\b",
|
| 53 |
+
r"\babortion\b", r"\bpregnant\b", r"\bmedicine\b", r"\bclinical\b",
|
| 54 |
+
r"\bsymptom\b", r"\bheat\b", r"\brabies\b", r"\bfever\b"],
|
| 55 |
+
"economy": [r"\beconomy\b", r"\binflation\b", r"\bgdp\b", r"\binterest rate\b",
|
| 56 |
+
r"\brecession\b", r"\bunemployment\b", r"\bmarket\b", r"\btariff\b",
|
| 57 |
+
r"\btrade war\b", r"\bdebt\b", r"\bstock\b", r"\bprice\b", r"\bcost\b",
|
| 58 |
+
r"\bfinancial\b"],
|
| 59 |
+
"space": [r"\bspace\b", r"\bnasa\b", r"\bspacex\b", r"\bmars\b", r"\brocket\b",
|
| 60 |
+
r"\bastronaut\b", r"\bgalaxy\b", r"\bplanet\b", r"\borgbit\b",
|
| 61 |
+
r"\bstellar\b", r"\bcosmic\b"],
|
| 62 |
+
"cybersecurity": [r"\bcyber\b", r"\bhack", r"\bsecurity breach\b",
|
| 63 |
+
r"\bdata breach\b", r"\bransomware\b", r"\bmalware\b",
|
| 64 |
+
r"\bphishing\b", r"\bzero.day\b", r"\bfirewall\b",
|
| 65 |
+
r"\bencryption\b", r"\bCVE\b", r"\bexploit\b",
|
| 66 |
+
r"\bbotnet\b", r"\bDDoS\b", r"\bvulnerability\b", r"\bfraud\b"],
|
| 67 |
+
"politics": [r"\belection\b", r"\bvot(?:e|ing|er)\b", r"\bcongress\b",
|
| 68 |
+
r"\bparliament\b", r"\bsenate\b", r"\bpresident\b",
|
| 69 |
+
r"\bgovern(?:ment|or)\b", r"\bGOP\b", r"\bDemocrat\b",
|
| 70 |
+
r"\brepublican\b", r"\bpolitician\b", r"\bcandidate\b",
|
| 71 |
+
r"\bambassador\b", r"\bdiplomat\b", r"\bsanction\b",
|
| 72 |
+
r"\btreaty\b", r"\bembassy\b", r"\bminister\b", r"\bregime\b",
|
| 73 |
+
r"\blegislat\b", r"\bpolicy\b", r"\bfederal\b"],
|
| 74 |
+
"science": [r"\bscien(?:ce|tist|tists|tific)\b", r"\bresearch\b", r"\bstudy\b",
|
| 75 |
+
r"\bdiscovery\b", r"\bgenome\b", r"\bquantum\b", r"\bparticle\b",
|
| 76 |
+
r"\bevolution\b", r"\bexperiment\b", r"\bjournal\b", r"\blab\b",
|
| 77 |
+
r"\bDNA\b", r"\bgene\b", r"\bprotein\b", r"\bbiolog\b",
|
| 78 |
+
r"\bchemical\b", r"\bphysics\b"],
|
| 79 |
+
"technology": [r"\btech\b", r"\bsoftware\b", r"\bhardware\b", r"\bchip\b",
|
| 80 |
+
r"\bsemiconductor\b", r"\bapp\b", r"\balgorithm\b",
|
| 81 |
+
r"\bcomputer\b", r"\brobot\b", r"\bgaming\b", r"\bvideo game\b",
|
| 82 |
+
r"\bconsole\b", r"\bmobile\b", r"\bphone\b", r"\blaptop\b",
|
| 83 |
+
r"\bsmartphone\b", r"\bgadget\b", r"\bstartup\b",
|
| 84 |
+
r"\bplatform\b", r"\bdeveloper\b", r"\bcode\b", r"\bprogramming\b",
|
| 85 |
+
r"\bdigital\b", r"\bcloud\b", r"\bdevice\b", r"\bsmart\b",
|
| 86 |
+
r"\bIoT\b", r"\bOS\b", r"\bWindows\b", r"\bAndroid\b", r"\biOS\b",
|
| 87 |
+
r"\bPlayStation\b", r"\bapp\b", r"\bAI\b", r"\bA\.I",
|
| 88 |
+
r"\bEV\b", r"\belectric vehicle\b", r"\bgadget\b",
|
| 89 |
+
r"\btechlash\b"],
|
| 90 |
+
"sports": [r"\bsport\b", r"\bfootball\b", r"\bsoccer\b", r"\bbasketball\b",
|
| 91 |
+
r"\btennis\b", r"\bworld cup\b", r"\bolympic\b"],
|
| 92 |
+
"education": [r"\beducation\b", r"\bschool\b", r"\buniversity\b",
|
| 93 |
+
r"\bstudent\b", r"\bteacher\b", r"\bcollege\b", r"\bcampus\b"],
|
| 94 |
+
"immigration": [r"\bimmigra(?:nt|tion)\b", r"\bborder\b", r"\basylum\b",
|
| 95 |
+
r"\brefugee\b", r"\bdeport\b", r"\bvisa\b"],
|
| 96 |
+
"energy": [r"\boil\b", r"\bgas\b", r"\bnuclear\b", r"\benergy\b",
|
| 97 |
+
r"\bpower plant\b", r"\brenewable\b", r"\bfossil fuel\b"],
|
| 98 |
+
"world": [r"\bwar\b", r"\bmilitary\b", r"\binvasion\b", r"\bsanction\b",
|
| 99 |
+
r"\bforeign\b", r"\bdiplomat\b", r"\btreaty\b", r"\bconflict\b",
|
| 100 |
+
r"\bearthquake\b", r"\bflood\b", r"\bdisaster\b", r"\bpresident\b",
|
| 101 |
+
r"\bprime minister\b", r"\bgeopolitic\b", r"\balliance\b",
|
| 102 |
+
r"\bmilitant\b", r"\bguerrilla\b", r"\bceasefire\b", r"\bterrorism\b",
|
| 103 |
+
r"\bUkraine\b", r"\bRussia\b", r"\bChina\b", r"\bIran\b",
|
| 104 |
+
r"\batomic\b", r"\bnuclear\b", r"\bmissile\b", r"\bdrone\b",
|
| 105 |
+
r"\battack\b", r"\bstrike\b", r"\bbomb\b", r"\btroop\b",
|
| 106 |
+
r"\bsoldier\b", r"\bmissile\b", r"\bdefence\b", r"\bdefense\b",
|
| 107 |
+
r"\bNATO\b", r"\bUN\b", r"\bICC\b", r"\bintelligence\b",
|
| 108 |
+
r"\bVatican\b", r"\bCatholic\b"],
|
| 109 |
+
"funny": [r"\bfunny\b", r"\bjoke\b", r"\bhumor\b", r"\bcomedy\b",
|
| 110 |
+
r"\bsatire\b", r"\bparody\b", r"\blol\b", r"\bwtf\b",
|
| 111 |
+
r"\babsurd\b", r"\bridiculous\b", r"\bhilarious\b", r"\bcomic\b",
|
| 112 |
+
r"\blaugh\b", r"\bclown\b"],
|
| 113 |
+
"weird": [r"\bweird\b", r"\bstrange\b", r"\bbizarre\b", r"\boddb?all\b",
|
| 114 |
+
r"\bpeculiar\b", r"\bunusual\b", r"\bodd\b", r"\bunbelievable\b",
|
| 115 |
+
r"\bsurreal\b", r"\bunconventional\b", r"\bwtf\b"],
|
| 116 |
+
"onion": [r"\bonion\b", r"\btheonion\b"],
|
| 117 |
+
"gaming": [r"\bgam(?:e|ing|er|ers)\b", r"\besport\b", r"\bplaystation\b",
|
| 118 |
+
r"\bxbox\b", r"\bnintendo\b", r"\bsteam\b", r"\bconsole\b",
|
| 119 |
+
r"\bgta\b", r"\bgrand theft auto\b", r"\bfortnite\b",
|
| 120 |
+
r"\bminecraft\b", r"\bvalorant\b", r"\bvideogame\b",
|
| 121 |
+
r"\bvideo game\b"],
|
| 122 |
+
"movies": [r"\bmovie\b", r"\bfilm\b", r"\bcinema\b", r"\bHollywood\b",
|
| 123 |
+
r"\bbox office\b", r"\bblockbuster\b", r"\bOscar\b",
|
| 124 |
+
r"\bactor\b", r"\bactress\b", r"\bscreenplay\b",
|
| 125 |
+
r"\bdirector\b", r"\bNetflix\b", r"\bDisney\+\b",
|
| 126 |
+
r"\bHBO\b", r"\breboot\b", r"\bsequel\b", r"\bprequel\b",
|
| 127 |
+
r"\bIMAX\b", r"\banimation\b"],
|
| 128 |
+
"tunisia": [r"\bTunisia\b", r"\bTunis\b", r"\bCarthage\b",
|
| 129 |
+
r"\bSousse\b", r"\bSfax\b"],
|
| 130 |
+
"arab_world": [r"\barab\b", r"\bgulf\b", r"\bmiddle east\b",
|
| 131 |
+
r"\bsaudi\b", r"\bQatar\b", r"\bUAE\b", r"\bDubai\b",
|
| 132 |
+
r"\bAbu Dhabi\b", r"\bDoha\b", r"\bRiyadh\b",
|
| 133 |
+
r"\bPalestin\b", r"\bGaza\b", r"\bWest Bank\b",
|
| 134 |
+
r"\bLeban\b", r"\bBeirut\b", r"\bBaghdad\b",
|
| 135 |
+
r"\bCairo\b", r"\bEgypt\b", r"\bSyria\b",
|
| 136 |
+
r"\bYemen\b", r"\bAmman\b", r"\bJordan\b",
|
| 137 |
+
r"\bOman\b", r"\bKuwait\b", r"\bBahrain\b",
|
| 138 |
+
r"\bUnrwa\b", r"\bHezbollah\b", r"\bHouthi\b",
|
| 139 |
+
r"\bOPEC\b", r"\bMENA\b"],
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
TOPIC_TO_CATEGORY: dict[str, str] = {
|
| 143 |
+
"politics": "Geopolitical",
|
| 144 |
+
"world": "Geopolitical",
|
| 145 |
+
"immigration": "Geopolitical",
|
| 146 |
+
"economy": "Geopolitical",
|
| 147 |
+
"energy": "Geopolitical",
|
| 148 |
+
"health": "World Health",
|
| 149 |
+
"science": "World Health",
|
| 150 |
+
"technology": "Tech",
|
| 151 |
+
"artificial intelligence": "Tech",
|
| 152 |
+
"space": "Tech",
|
| 153 |
+
"cybersecurity": "Cybersecurity",
|
| 154 |
+
"funny": "Funny/Weird",
|
| 155 |
+
"weird": "Funny/Weird",
|
| 156 |
+
"onion": "Funny/Weird",
|
| 157 |
+
"sports": "Funny/Weird",
|
| 158 |
+
"education": "Geopolitical",
|
| 159 |
+
"climate change": "Geopolitical",
|
| 160 |
+
"gaming": "Gaming",
|
| 161 |
+
"movies": "Movies",
|
| 162 |
+
"tunisia": "Tunisia",
|
| 163 |
+
"arab_world": "Arab World",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
FACTUAL_KEYWORDS = [
|
| 167 |
+
r"\breport\b", r"\baccording to\b", r"\bsource said\b", r"\bstated\b",
|
| 168 |
+
r"\bstudy found\b", r"\bdata show\b", r"\bofficial said\b",
|
| 169 |
+
r"\bresearch suggests\b", r"\bthe study\b", r"\bsurvey\b",
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class NewsAnalyzer:
|
| 174 |
+
def __init__(self, config):
|
| 175 |
+
self.config = config
|
| 176 |
+
self._summariser = None
|
| 177 |
+
self._classifier = None
|
| 178 |
+
self._setup_models()
|
| 179 |
+
|
| 180 |
+
def _setup_models(self):
|
| 181 |
+
if not self.config.use_local_models:
|
| 182 |
+
logger.info("Local models disabled — using rule-based analysis")
|
| 183 |
+
return
|
| 184 |
+
try:
|
| 185 |
+
from transformers import pipeline
|
| 186 |
+
logger.info("Loading summariser: %s ...", self.config.summarization_model)
|
| 187 |
+
self._summariser = pipeline(
|
| 188 |
+
"summarization",
|
| 189 |
+
model=self.config.summarization_model,
|
| 190 |
+
tokenizer=self.config.summarization_model,
|
| 191 |
+
)
|
| 192 |
+
logger.info("Loading zero-shot classifier ...")
|
| 193 |
+
self._classifier = pipeline(
|
| 194 |
+
"zero-shot-classification",
|
| 195 |
+
model="typeform/distilbert-base-uncased-mnli",
|
| 196 |
+
)
|
| 197 |
+
except ImportError:
|
| 198 |
+
logger.warning("transformers not available — using rule-based analysis")
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
logger.warning("Model loading failed: %s — using rule-based", exc)
|
| 201 |
+
|
| 202 |
+
def analyze(self, article: Article) -> Analysis:
|
| 203 |
+
summary = self._summarise(article)
|
| 204 |
+
topics = self._classify_topics(article, summary)
|
| 205 |
+
trust = self._assess_trustworthiness(article)
|
| 206 |
+
is_opinion = self._detect_opinion(article.text or "")
|
| 207 |
+
leaning = self._detect_political_leaning(article.text or "")
|
| 208 |
+
|
| 209 |
+
category = self._map_category(topics)
|
| 210 |
+
|
| 211 |
+
return Analysis(
|
| 212 |
+
summary=summary,
|
| 213 |
+
topics=topics,
|
| 214 |
+
trustworthiness_score=trust,
|
| 215 |
+
is_opinion=is_opinion,
|
| 216 |
+
political_leaning=leaning,
|
| 217 |
+
category=category,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def _summarise(self, article: Article) -> str:
|
| 221 |
+
title = article.title or ""
|
| 222 |
+
text = article.text or ""
|
| 223 |
+
|
| 224 |
+
if self._summariser:
|
| 225 |
+
try:
|
| 226 |
+
input_text = text[:1024]
|
| 227 |
+
out = self._summariser(input_text, max_length=130, min_length=30,
|
| 228 |
+
do_sample=False)
|
| 229 |
+
return out[0]["summary_text"]
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
logger.debug("Summariser failed: %s", exc)
|
| 232 |
+
|
| 233 |
+
body = self._strip_metadata(text)
|
| 234 |
+
body = self._strip_title_line(body, title)
|
| 235 |
+
|
| 236 |
+
if not body or len(body) < len(title) * 1.5:
|
| 237 |
+
return ""
|
| 238 |
+
|
| 239 |
+
title_norm = self._norm(title)
|
| 240 |
+
sentences = re.split(r"(?<=[.!?])\s+", body.strip())
|
| 241 |
+
selected = []
|
| 242 |
+
for s in sentences:
|
| 243 |
+
s = s.strip()
|
| 244 |
+
if not s:
|
| 245 |
+
continue
|
| 246 |
+
if self._is_title_like(s, title_norm):
|
| 247 |
+
continue
|
| 248 |
+
selected.append(s)
|
| 249 |
+
if len(selected) >= 2:
|
| 250 |
+
break
|
| 251 |
+
return " ".join(selected) if selected else ""
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
def _norm(s: str) -> str:
|
| 255 |
+
return re.sub(r"\s+", " ", html.unescape(s).lower().strip()).rstrip(".")
|
| 256 |
+
|
| 257 |
+
@staticmethod
|
| 258 |
+
def _is_title_like(sentence: str, title_norm: str) -> bool:
|
| 259 |
+
s_norm = re.sub(r"\s+", " ", sentence.lower().strip()).rstrip(".")
|
| 260 |
+
if s_norm == title_norm:
|
| 261 |
+
return True
|
| 262 |
+
words_s = set(s_norm.split())
|
| 263 |
+
words_t = set(title_norm.split())
|
| 264 |
+
if not words_s or not words_t:
|
| 265 |
+
return False
|
| 266 |
+
short, long = (words_s, words_t) if len(words_s) < len(words_t) else (words_t, words_s)
|
| 267 |
+
overlap = len(short & long) / max(len(short), len(long))
|
| 268 |
+
return overlap > 0.7
|
| 269 |
+
|
| 270 |
+
@staticmethod
|
| 271 |
+
def _strip_title_line(text: str, title: str) -> str:
|
| 272 |
+
lines = text.split("\n")
|
| 273 |
+
if not lines:
|
| 274 |
+
return text
|
| 275 |
+
first = lines[0].strip()
|
| 276 |
+
if not first:
|
| 277 |
+
return "\n".join(lines[1:]).strip()
|
| 278 |
+
if len(first) < 150 and not re.search(r"[.!?]$", first):
|
| 279 |
+
return "\n".join(lines[1:]).strip()
|
| 280 |
+
return text
|
| 281 |
+
|
| 282 |
+
@staticmethod
|
| 283 |
+
def _strip_metadata(text: str) -> str:
|
| 284 |
+
lines = text.split("\n")
|
| 285 |
+
cleaned = []
|
| 286 |
+
for line in lines:
|
| 287 |
+
clean = line.strip()
|
| 288 |
+
if re.match(r"^\s*[—\-] (Published|Updated|BBC News|Image|Copyright)", clean, re.IGNORECASE):
|
| 289 |
+
continue
|
| 290 |
+
cleaned.append(line)
|
| 291 |
+
return "\n".join(cleaned).strip()
|
| 292 |
+
|
| 293 |
+
def _classify_topics(self, article: Article, summary: str) -> list[str]:
|
| 294 |
+
if self._classifier:
|
| 295 |
+
try:
|
| 296 |
+
text = f"{article.title} {summary}" if summary else article.title
|
| 297 |
+
candidates = self.config.user_interests + ["other"]
|
| 298 |
+
result = self._classifier(text[:512], candidates)
|
| 299 |
+
return [
|
| 300 |
+
label for label, score in zip(result["labels"], result["scores"])
|
| 301 |
+
if score > 0.25
|
| 302 |
+
]
|
| 303 |
+
except Exception as exc:
|
| 304 |
+
logger.debug("Classifier failed: %s", exc)
|
| 305 |
+
|
| 306 |
+
return self._keyword_topic_match(article, summary)
|
| 307 |
+
|
| 308 |
+
DOMAIN_TOPICS: dict[str, str] = {
|
| 309 |
+
"krebsonsecurity.com": "cybersecurity",
|
| 310 |
+
"bleepingcomputer.com": "cybersecurity",
|
| 311 |
+
"theonion.com": "onion",
|
| 312 |
+
"ign.com": "gaming",
|
| 313 |
+
"eurogamer.net": "gaming",
|
| 314 |
+
"pcgamer.com": "gaming",
|
| 315 |
+
"rockpapershotgun.com": "gaming",
|
| 316 |
+
"kotaku.com": "gaming",
|
| 317 |
+
"gamespot.com": "gaming",
|
| 318 |
+
"arabnews.com": "arab_world",
|
| 319 |
+
"middleeasteye.net": "arab_world",
|
| 320 |
+
"thenationalnews.com": "arab_world",
|
| 321 |
+
"newarab.com": "arab_world",
|
| 322 |
+
"therecord.media": "cybersecurity",
|
| 323 |
+
"threatpost.com": "cybersecurity",
|
| 324 |
+
"thedailymash.co.uk": "funny",
|
| 325 |
+
"babylonbee.com": "onion",
|
| 326 |
+
"polygon.com": "gaming",
|
| 327 |
+
"variety.com": "movies",
|
| 328 |
+
"hollywoodreporter.com": "movies",
|
| 329 |
+
"deadline.com": "movies",
|
| 330 |
+
"screenrant.com": "movies",
|
| 331 |
+
"tunisiaonlinenews.com": "tunisia",
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
def _keyword_topic_match(self, article: Article, summary: str) -> list[str]:
|
| 335 |
+
text = f"{article.title} {summary}" if summary else article.title
|
| 336 |
+
found = []
|
| 337 |
+
for topic, patterns in TOPIC_MAP.items():
|
| 338 |
+
if any(re.search(p, text, re.IGNORECASE) for p in patterns):
|
| 339 |
+
found.append(topic)
|
| 340 |
+
domain = re.sub(r"^www\.", "", (article.source_domain or ""))
|
| 341 |
+
mapped = self.DOMAIN_TOPICS.get(domain)
|
| 342 |
+
# Only use domain fallback if keyword matching found nothing
|
| 343 |
+
if mapped and mapped not in found:
|
| 344 |
+
if not found:
|
| 345 |
+
found.append(mapped)
|
| 346 |
+
return found
|
| 347 |
+
|
| 348 |
+
def _assess_trustworthiness(self, article: Article) -> float:
|
| 349 |
+
score = 0.40
|
| 350 |
+
|
| 351 |
+
domain = article.source_domain or ""
|
| 352 |
+
clean_domain = re.sub(r"^www\.", "", domain)
|
| 353 |
+
score += RELIABLE_DOMAINS.get(clean_domain, 0.0)
|
| 354 |
+
score += UNRELIABLE_DOMAINS.get(clean_domain, 0.0)
|
| 355 |
+
|
| 356 |
+
text = article.text or ""
|
| 357 |
+
title = article.title or ""
|
| 358 |
+
if article.extraction_success is False:
|
| 359 |
+
score -= 0.15
|
| 360 |
+
elif len(text) > len(title) * 3:
|
| 361 |
+
score += 0.05
|
| 362 |
+
|
| 363 |
+
word_count = len(text.split())
|
| 364 |
+
if word_count > 200:
|
| 365 |
+
score += 0.10
|
| 366 |
+
elif word_count > 100:
|
| 367 |
+
score += 0.05
|
| 368 |
+
elif word_count > 50:
|
| 369 |
+
score += 0.02
|
| 370 |
+
|
| 371 |
+
factual_count = sum(1 for p in FACTUAL_KEYWORDS if re.search(p, text, re.IGNORECASE))
|
| 372 |
+
score += min(factual_count * 0.02, 0.08)
|
| 373 |
+
|
| 374 |
+
if any(re.search(p, title, re.IGNORECASE) for p in CLICKBAIT_PATTERNS):
|
| 375 |
+
score -= 0.20
|
| 376 |
+
|
| 377 |
+
opinion_count = sum(1 for p in OPINION_MARKERS if re.search(p, text, re.IGNORECASE))
|
| 378 |
+
score -= opinion_count * 0.05
|
| 379 |
+
|
| 380 |
+
return max(0.05, min(1.0, score))
|
| 381 |
+
|
| 382 |
+
@staticmethod
|
| 383 |
+
def _map_category(topics: list[str]) -> str:
|
| 384 |
+
priority = ["onion", "funny", "weird", "cybersecurity", "gaming",
|
| 385 |
+
"technology", "artificial intelligence", "health", "science",
|
| 386 |
+
"tunisia", "arab_world", "world", "politics", "immigration", "economy",
|
| 387 |
+
"energy", "education", "climate change", "space", "sports", "movies"]
|
| 388 |
+
topic_set = {t.lower() for t in topics}
|
| 389 |
+
for p in priority:
|
| 390 |
+
if p in topic_set:
|
| 391 |
+
mapped = TOPIC_TO_CATEGORY.get(p)
|
| 392 |
+
if mapped:
|
| 393 |
+
return mapped
|
| 394 |
+
return "General"
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def _detect_opinion(text: str) -> bool:
|
| 398 |
+
count = sum(1 for p in OPINION_MARKERS if re.search(p, text.lower()))
|
| 399 |
+
return count >= 3
|
| 400 |
+
|
| 401 |
+
@staticmethod
|
| 402 |
+
def _detect_political_leaning(text: str) -> str:
|
| 403 |
+
text_lower = text.lower()
|
| 404 |
+
left = sum(1 for k in LEFT_KEYWORDS if k in text_lower)
|
| 405 |
+
right = sum(1 for k in RIGHT_KEYWORDS if k in text_lower)
|
| 406 |
+
if left > right + 1:
|
| 407 |
+
return "left-leaning"
|
| 408 |
+
if right > left + 1:
|
| 409 |
+
return "right-leaning"
|
| 410 |
+
return "centrist"
|
src/src/extractor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
from .models import Article
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ArticleExtractor:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self._trafilatura = None
|
| 13 |
+
self._init_backend()
|
| 14 |
+
|
| 15 |
+
def _init_backend(self):
|
| 16 |
+
try:
|
| 17 |
+
import trafilatura
|
| 18 |
+
self._trafilatura = trafilatura
|
| 19 |
+
except ImportError:
|
| 20 |
+
logger.info("trafilatura not installed — using fallback extractor")
|
| 21 |
+
|
| 22 |
+
def extract(self, url: str) -> Optional[Article]:
|
| 23 |
+
domain = urlparse(url).netloc
|
| 24 |
+
try:
|
| 25 |
+
if self._trafilatura:
|
| 26 |
+
return self._extract_trafilatura(url, domain)
|
| 27 |
+
return self._extract_fallback(url, domain)
|
| 28 |
+
except Exception as exc:
|
| 29 |
+
logger.debug("Extraction failed for %s: %s", url, exc)
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
def _extract_trafilatura(self, url: str, domain: str) -> Optional[Article]:
|
| 33 |
+
downloaded = self._trafilatura.fetch_url(url)
|
| 34 |
+
if not downloaded:
|
| 35 |
+
return None
|
| 36 |
+
text = self._trafilatura.extract(downloaded)
|
| 37 |
+
if not text:
|
| 38 |
+
return None
|
| 39 |
+
title = self._extract_title_meta(downloaded) or ""
|
| 40 |
+
image = self._extract_og_image(downloaded)
|
| 41 |
+
return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
|
| 42 |
+
|
| 43 |
+
def _extract_fallback(self, url: str, domain: str) -> Optional[Article]:
|
| 44 |
+
resp = requests.get(url, headers={"User-Agent": "newsapp/1.0"}, timeout=15)
|
| 45 |
+
resp.raise_for_status()
|
| 46 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 47 |
+
|
| 48 |
+
title = ""
|
| 49 |
+
if soup.title:
|
| 50 |
+
title = soup.title.get_text(strip=True)
|
| 51 |
+
|
| 52 |
+
image = self._extract_og_image_soup(soup)
|
| 53 |
+
|
| 54 |
+
paragraphs = soup.find_all("p")
|
| 55 |
+
text = "\n\n".join(p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True))
|
| 56 |
+
if not text:
|
| 57 |
+
return None
|
| 58 |
+
return Article(url=url, title=title, text=text, source_domain=domain, image_url=image)
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _extract_title_meta(html: str) -> Optional[str]:
|
| 62 |
+
import html as html_mod
|
| 63 |
+
import re
|
| 64 |
+
m = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
| 65 |
+
return html_mod.unescape(m.group(1).strip()) if m else None
|
| 66 |
+
|
| 67 |
+
@staticmethod
|
| 68 |
+
def _extract_og_image(html: str) -> str:
|
| 69 |
+
import re
|
| 70 |
+
m = re.search(
|
| 71 |
+
r'<meta\s+[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']',
|
| 72 |
+
html, re.IGNORECASE,
|
| 73 |
+
)
|
| 74 |
+
if m:
|
| 75 |
+
return m.group(1)
|
| 76 |
+
m = re.search(
|
| 77 |
+
r'<meta\s+[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']',
|
| 78 |
+
html, re.IGNORECASE,
|
| 79 |
+
)
|
| 80 |
+
return m.group(1) if m else ""
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def _extract_og_image_soup(soup) -> str:
|
| 84 |
+
for prop in ("og:image", "twitter:image"):
|
| 85 |
+
tag = soup.find("meta", property=prop) or soup.find("meta", attrs={"name": prop})
|
| 86 |
+
if tag and tag.get("content"):
|
| 87 |
+
return tag["content"]
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
import requests
|
| 92 |
+
from bs4 import BeautifulSoup
|
src/src/hn_scraper.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
from .models import RedditPost
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
API_BASE = "https://hacker-news.firebaseio.com/v0"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class HackerNewsScraper:
|
| 15 |
+
def __init__(self, config):
|
| 16 |
+
self.config = config
|
| 17 |
+
|
| 18 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 19 |
+
resp = requests.get(f"{API_BASE}/topstories.json", timeout=15)
|
| 20 |
+
resp.raise_for_status()
|
| 21 |
+
all_ids = resp.json()
|
| 22 |
+
|
| 23 |
+
limit = min(self.config.posts_per_subreddit * 3, 50)
|
| 24 |
+
posts = []
|
| 25 |
+
for story_id in all_ids[:limit]:
|
| 26 |
+
try:
|
| 27 |
+
detail = requests.get(f"{API_BASE}/item/{story_id}.json", timeout=10)
|
| 28 |
+
detail.raise_for_status()
|
| 29 |
+
data = detail.json()
|
| 30 |
+
if not data or data.get("type") != "story":
|
| 31 |
+
continue
|
| 32 |
+
url = data.get("url") or f"https://news.ycombinator.com/item?id={story_id}"
|
| 33 |
+
posts.append(RedditPost(
|
| 34 |
+
id=f"hn_{story_id}",
|
| 35 |
+
title=data.get("title", ""),
|
| 36 |
+
url=url,
|
| 37 |
+
subreddit="hackernews",
|
| 38 |
+
score=data.get("score", 0),
|
| 39 |
+
num_comments=data.get("descendants", 0),
|
| 40 |
+
source_domain=urlparse(url).netloc,
|
| 41 |
+
))
|
| 42 |
+
except Exception as exc:
|
| 43 |
+
logger.debug("Failed to fetch HN item %s: %s", story_id, exc)
|
| 44 |
+
|
| 45 |
+
posts.sort(key=lambda p: p.score, reverse=True)
|
| 46 |
+
posts = posts[: self.config.posts_per_subreddit]
|
| 47 |
+
logger.info("Fetched %d posts from Hacker News", len(posts))
|
| 48 |
+
return posts
|
src/src/mockdata.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
from .models import Analysis, Article, NewsItem, RedditPost
|
| 5 |
+
|
| 6 |
+
_MOCK_ARTICLES = [
|
| 7 |
+
{
|
| 8 |
+
"title": "New AI model achieves breakthrough in protein folding prediction",
|
| 9 |
+
"text": (
|
| 10 |
+
"Researchers at DeepMind and several universities have announced a major breakthrough "
|
| 11 |
+
"in protein folding prediction using a new deep learning architecture. The model, called "
|
| 12 |
+
"AlphaFold-Next, is able to predict protein structures with accuracy approaching "
|
| 13 |
+
"experimental methods. This advancement could accelerate drug discovery and our "
|
| 14 |
+
"understanding of diseases. The team trained the model on a dataset of over 100,000 "
|
| 15 |
+
"known protein structures and used a novel attention mechanism to capture long-range "
|
| 16 |
+
"interactions between amino acids. Early tests show the model generalises well to "
|
| 17 |
+
"previously unseen protein families."
|
| 18 |
+
),
|
| 19 |
+
"domain": "nature.com",
|
| 20 |
+
"subreddit": "science",
|
| 21 |
+
"score": 5420,
|
| 22 |
+
"comments": 342,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"title": "WHO declares new global health emergency as novel virus spreads across continents",
|
| 26 |
+
"text": (
|
| 27 |
+
"The World Health Organization has declared a Public Health Emergency of International "
|
| 28 |
+
"Concern as a novel respiratory virus continues to spread rapidly across multiple "
|
| 29 |
+
"continents. The virus, which emerged in Southeast Asia, has been detected in 15 "
|
| 30 |
+
"countries so far. Health officials are implementing containment measures including "
|
| 31 |
+
"travel restrictions and increased surveillance. The WHO is coordinating with national "
|
| 32 |
+
"health agencies to ensure a rapid response. Vaccines are expected to begin clinical "
|
| 33 |
+
"trials within six months, according to the Director-General."
|
| 34 |
+
),
|
| 35 |
+
"domain": "reuters.com",
|
| 36 |
+
"subreddit": "worldnews",
|
| 37 |
+
"score": 8210,
|
| 38 |
+
"comments": 2801,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"title": "New study links ultra-processed foods to increased cancer risk",
|
| 42 |
+
"text": (
|
| 43 |
+
"A comprehensive study published in The Lancet has found a significant correlation "
|
| 44 |
+
"between consumption of ultra-processed foods and increased risk of developing certain "
|
| 45 |
+
"types of cancer. The study followed over 200,000 participants for 15 years and "
|
| 46 |
+
"controlled for lifestyle factors such as smoking and exercise. Researchers found that "
|
| 47 |
+
"participants who consumed the highest levels of ultra-processed foods had a 23% higher "
|
| 48 |
+
"risk of developing colorectal cancer. The findings add to growing evidence that dietary "
|
| 49 |
+
"patterns play a crucial role in cancer prevention."
|
| 50 |
+
),
|
| 51 |
+
"domain": "bbc.com",
|
| 52 |
+
"subreddit": "health",
|
| 53 |
+
"score": 3890,
|
| 54 |
+
"comments": 567,
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"title": "SpaceX successfully launches satellite constellation for global internet coverage",
|
| 58 |
+
"text": (
|
| 59 |
+
"SpaceX has successfully launched another batch of 60 Starlink satellites, bringing the "
|
| 60 |
+
"total constellation size to over 5,000. The Falcon 9 rocket lifted off from Cape "
|
| 61 |
+
"Canaveral and successfully deployed the satellites in low Earth orbit. This expansion "
|
| 62 |
+
"will bring high-speed internet access to previously unserved rural areas across the "
|
| 63 |
+
"globe. The company plans to increase the constellation to 12,000 satellites within "
|
| 64 |
+
"the next three years, with initial tests showing latency as low as 20 milliseconds."
|
| 65 |
+
),
|
| 66 |
+
"domain": "reuters.com",
|
| 67 |
+
"subreddit": "technology",
|
| 68 |
+
"score": 4560,
|
| 69 |
+
"comments": 890,
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"title": "Federal Reserve signals interest rate cut as inflation continues to cool",
|
| 73 |
+
"text": (
|
| 74 |
+
"The Federal Reserve has signalled it may cut interest rates at its next meeting as "
|
| 75 |
+
"inflation continues to trend downward toward the 2% target. Recent economic data "
|
| 76 |
+
"shows consumer prices rose only 2.3% year-over-year, down from a peak of 9.1% two "
|
| 77 |
+
"years ago. Fed Chair Jerome Powell stated that while progress has been made, the "
|
| 78 |
+
"committee would remain data-dependent. Markets responded positively, with the S&P "
|
| 79 |
+
"500 rising 1.2% on the news. Economists expect a quarter-point cut in September."
|
| 80 |
+
),
|
| 81 |
+
"domain": "wsj.com",
|
| 82 |
+
"subreddit": "economy",
|
| 83 |
+
"score": 3200,
|
| 84 |
+
"comments": 1200,
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"title": "Global climate summit reaches historic agreement on fossil fuel phase-out",
|
| 88 |
+
"text": (
|
| 89 |
+
"Nearly 200 nations have reached a landmark agreement to phase out fossil fuels at the "
|
| 90 |
+
"UN Climate Summit in Dubai. The agreement sets a timeline for reducing coal, oil, and "
|
| 91 |
+
"gas production, with developed nations committing to faster reductions. Developing "
|
| 92 |
+
"countries will receive financial support through a new climate fund worth $100 billion "
|
| 93 |
+
"annually. Environmental groups have cautiously welcomed the deal while noting that "
|
| 94 |
+
"the timeline may need to accelerate to meet Paris Agreement targets. The agreement "
|
| 95 |
+
"marks the first time all nations have explicitly committed to transitioning away from "
|
| 96 |
+
"fossil fuels."
|
| 97 |
+
),
|
| 98 |
+
"domain": "theguardian.com",
|
| 99 |
+
"subreddit": "worldnews",
|
| 100 |
+
"score": 9500,
|
| 101 |
+
"comments": 3400,
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"title": "Cybersecurity researchers discover zero-day exploit affecting billions of devices",
|
| 105 |
+
"text": (
|
| 106 |
+
"Security researchers have discovered a critical vulnerability in a widely-used "
|
| 107 |
+
"networking library that affects an estimated 3 billion devices worldwide. The "
|
| 108 |
+
"zero-day exploit, dubbed 'PacketStorm', allows remote code execution without user "
|
| 109 |
+
"interaction. Major technology companies including Google, Apple, and Microsoft have "
|
| 110 |
+
"released emergency patches. Users are strongly advised to update their devices "
|
| 111 |
+
"immediately. The vulnerability has been present in the codebase for over a decade "
|
| 112 |
+
"and was discovered during a routine security audit."
|
| 113 |
+
),
|
| 114 |
+
"domain": "reuters.com",
|
| 115 |
+
"subreddit": "technology",
|
| 116 |
+
"score": 6700,
|
| 117 |
+
"comments": 1500,
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"title": "Unprecedented heatwave breaks temperature records across Europe",
|
| 121 |
+
"text": (
|
| 122 |
+
"An unprecedented heatwave is sweeping across Europe, with temperatures exceeding "
|
| 123 |
+
"45°C in several countries. Multiple heat records have been broken, including the "
|
| 124 |
+
"all-time high for the United Kingdom at 42.3°C. Authorities have issued red alerts "
|
| 125 |
+
"and are urging residents to stay indoors. The extreme weather has been linked to "
|
| 126 |
+
"climate change by leading meteorological agencies. Hospitals are reporting increased "
|
| 127 |
+
"admissions for heat-related illnesses, and transport networks have been disrupted "
|
| 128 |
+
"due to heat-damaged infrastructure."
|
| 129 |
+
),
|
| 130 |
+
"domain": "bbc.com",
|
| 131 |
+
"subreddit": "worldnews",
|
| 132 |
+
"score": 7800,
|
| 133 |
+
"comments": 2100,
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"title": "New CRISPR therapy shows promising results in clinical trial for sickle cell disease",
|
| 137 |
+
"text": (
|
| 138 |
+
"A groundbreaking CRISPR-based gene therapy has shown remarkable results in a Phase 3 "
|
| 139 |
+
"clinical trial for sickle cell disease. Out of 45 patients, 42 showed complete "
|
| 140 |
+
"remission of symptoms 12 months after treatment. The therapy, developed by Vertex "
|
| 141 |
+
"Pharmaceuticals, uses CRISPR-Cas9 to edit the patient's own stem cells, correcting "
|
| 142 |
+
"the genetic mutation responsible for the disease. The FDA has granted breakthrough "
|
| 143 |
+
"therapy designation, potentially accelerating approval. This marks one of the first "
|
| 144 |
+
"successful CRISPR-based treatments for a genetic blood disorder."
|
| 145 |
+
),
|
| 146 |
+
"domain": "nature.com",
|
| 147 |
+
"subreddit": "science",
|
| 148 |
+
"score": 5100,
|
| 149 |
+
"comments": 450,
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"title": "AI regulation bill passes Senate with bipartisan support",
|
| 153 |
+
"text": (
|
| 154 |
+
"The US Senate has passed a landmark artificial intelligence regulation bill with "
|
| 155 |
+
"significant bipartisan support. The legislation requires AI companies to conduct "
|
| 156 |
+
"safety testing before releasing powerful models, establish transparency requirements, "
|
| 157 |
+
"and create a new federal agency to oversee AI development. The bill was co-sponsored "
|
| 158 |
+
"by senators from both parties and represents one of the most comprehensive AI "
|
| 159 |
+
"governance frameworks in the world. Tech companies have expressed mixed reactions, "
|
| 160 |
+
"with some supporting the clarity while others worry about innovation impact."
|
| 161 |
+
),
|
| 162 |
+
"domain": "nytimes.com",
|
| 163 |
+
"subreddit": "politics",
|
| 164 |
+
"score": 4300,
|
| 165 |
+
"comments": 1800,
|
| 166 |
+
},
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def generate_demo_items() -> list[NewsItem]:
|
| 171 |
+
items = []
|
| 172 |
+
for art in _MOCK_ARTICLES:
|
| 173 |
+
post = RedditPost(
|
| 174 |
+
id=f"mock_{random.randint(100000, 999999)}",
|
| 175 |
+
title=art["title"],
|
| 176 |
+
url=f"https://{art['domain']}/article/{random.randint(1000, 9999)}",
|
| 177 |
+
subreddit=art["subreddit"],
|
| 178 |
+
score=art["score"],
|
| 179 |
+
num_comments=art["comments"],
|
| 180 |
+
source_domain=art["domain"],
|
| 181 |
+
)
|
| 182 |
+
article = Article(
|
| 183 |
+
url=post.url,
|
| 184 |
+
title=art["title"],
|
| 185 |
+
text=art["text"],
|
| 186 |
+
source_domain=art["domain"],
|
| 187 |
+
)
|
| 188 |
+
items.append(NewsItem(post=post, article=article))
|
| 189 |
+
return items
|
src/src/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class RedditPost:
|
| 7 |
+
id: str
|
| 8 |
+
title: str
|
| 9 |
+
url: str
|
| 10 |
+
subreddit: str
|
| 11 |
+
score: int
|
| 12 |
+
num_comments: int
|
| 13 |
+
source_domain: str = ""
|
| 14 |
+
image_url: str = ""
|
| 15 |
+
published: str = ""
|
| 16 |
+
published_iso: str = ""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Article:
|
| 21 |
+
url: str
|
| 22 |
+
title: str
|
| 23 |
+
text: str
|
| 24 |
+
source_domain: str
|
| 25 |
+
extraction_success: bool = True
|
| 26 |
+
image_url: str = ""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class Analysis:
|
| 31 |
+
summary: str
|
| 32 |
+
topics: list[str]
|
| 33 |
+
trustworthiness_score: float
|
| 34 |
+
is_opinion: bool
|
| 35 |
+
political_leaning: str = "centrist"
|
| 36 |
+
category: str = "General"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class NewsItem:
|
| 41 |
+
post: RedditPost
|
| 42 |
+
article: Optional[Article] = None
|
| 43 |
+
analysis: Optional[Analysis] = None
|
| 44 |
+
final_score: float = 0.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class NewsCluster:
|
| 49 |
+
topic: str
|
| 50 |
+
articles: list[NewsItem]
|
| 51 |
+
total_coverage: int
|
| 52 |
+
avg_trustworthiness: float
|
| 53 |
+
avg_popularity: float
|
| 54 |
+
top_post_url: str
|
| 55 |
+
final_score: float = 0.0
|
| 56 |
+
image_url: str = ""
|
src/src/presenter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import defaultdict
|
| 2 |
+
|
| 3 |
+
from .models import NewsCluster
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class NewsPresenter:
|
| 7 |
+
CATEGORIES = ["Geopolitical", "World Health", "Tech", "Cybersecurity", "Funny/Weird", "Gaming", "Movies", "Arab World", "Tunisia"]
|
| 8 |
+
|
| 9 |
+
@staticmethod
|
| 10 |
+
def display(clusters: list[NewsCluster], top_n: int = 10):
|
| 11 |
+
if not clusters:
|
| 12 |
+
print("\n No news stories matched your interests this round.")
|
| 13 |
+
return
|
| 14 |
+
|
| 15 |
+
by_cat: dict[str, list[NewsCluster]] = defaultdict(list)
|
| 16 |
+
for c in clusters:
|
| 17 |
+
cat = "General"
|
| 18 |
+
if c.articles and c.articles[0].analysis:
|
| 19 |
+
ca = c.articles[0].analysis.category
|
| 20 |
+
cat = ca if ca in NewsPresenter.CATEGORIES else "General"
|
| 21 |
+
by_cat[cat].append(c)
|
| 22 |
+
|
| 23 |
+
print("╔" + "═" * 78 + "╗")
|
| 24 |
+
print("║ 📰 NEWS DIGEST — Top Stories ║".center(80))
|
| 25 |
+
print("╚" + "═" * 78 + "╝")
|
| 26 |
+
|
| 27 |
+
for cat in NewsPresenter.CATEGORIES:
|
| 28 |
+
items = by_cat.get(cat, [])
|
| 29 |
+
items.sort(key=lambda x: (x.articles[0].post.published_iso or "", x.final_score), reverse=True)
|
| 30 |
+
items = items[:top_n]
|
| 31 |
+
if not items:
|
| 32 |
+
continue
|
| 33 |
+
print(f"\n ══ {cat} ({len(items)}) ══\n")
|
| 34 |
+
for i, cluster in enumerate(items, 1):
|
| 35 |
+
print(f" #{i:<2} [{cluster.topic:<30}] "
|
| 36 |
+
f"Score: {cluster.final_score:.2f} "
|
| 37 |
+
f"Trust: {cluster.avg_trustworthiness:.0%}")
|
| 38 |
+
item = cluster.articles[0]
|
| 39 |
+
title = item.post.title[:72] + "…" if len(item.post.title) > 72 else item.post.title
|
| 40 |
+
print(f" {title}")
|
| 41 |
+
if item.post.published:
|
| 42 |
+
print(f" 📅 {item.post.published}")
|
| 43 |
+
if item.analysis and item.analysis.summary:
|
| 44 |
+
short = item.analysis.summary[:72] + "…" if len(item.analysis.summary) > 72 else item.analysis.summary
|
| 45 |
+
print(f" → {short}")
|
| 46 |
+
print()
|
| 47 |
+
|
| 48 |
+
print("▔" * 80)
|
src/src/rss_feed_scraper.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import time
|
| 3 |
+
from email.utils import parsedate_to_datetime
|
| 4 |
+
from urllib.parse import urlparse, urlencode, parse_qs
|
| 5 |
+
|
| 6 |
+
import feedparser
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from .models import RedditPost
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
TRACKING_PARAMS = {"at_medium", "at_campaign", "ref", "utm_source", "utm_medium", "utm_campaign"}
|
| 14 |
+
|
| 15 |
+
DEFAULT_FEEDS = [
|
| 16 |
+
# Geopolitical
|
| 17 |
+
"https://feeds.bbci.co.uk/news/world/rss.xml",
|
| 18 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
| 19 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml",
|
| 20 |
+
"https://feeds.npr.org/1001/rss.xml",
|
| 21 |
+
"https://www.aljazeera.com/xml/rss/all.xml",
|
| 22 |
+
"https://www.theguardian.com/world/rss",
|
| 23 |
+
# World Health
|
| 24 |
+
"https://feeds.bbci.co.uk/news/health/rss.xml",
|
| 25 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Science.xml",
|
| 26 |
+
"https://www.statnews.com/feed/",
|
| 27 |
+
"https://www.sciencedaily.com/rss/all.xml",
|
| 28 |
+
# Tech
|
| 29 |
+
"https://feeds.bbci.co.uk/news/technology/rss.xml",
|
| 30 |
+
"https://rss.nytimes.com/services/xml/rss/nyt/Technology.xml",
|
| 31 |
+
"https://techcrunch.com/feed/",
|
| 32 |
+
"https://www.wired.com/feed/rss",
|
| 33 |
+
"https://www.theverge.com/rss/index.xml",
|
| 34 |
+
"https://arstechnica.com/feed/",
|
| 35 |
+
# Cybersecurity
|
| 36 |
+
"https://feeds.feedburner.com/TheHackerNews",
|
| 37 |
+
"https://krebsonsecurity.com/feed/",
|
| 38 |
+
"https://www.bleepingcomputer.com/feed/",
|
| 39 |
+
"https://threatpost.com/feed/",
|
| 40 |
+
"https://therecord.media/feed/",
|
| 41 |
+
# Funny / Weird
|
| 42 |
+
"https://www.theonion.com/rss",
|
| 43 |
+
"https://www.reddit.com/r/nottheonion/.rss",
|
| 44 |
+
"https://www.thedailymash.co.uk/feed",
|
| 45 |
+
"https://babylonbee.com/feed",
|
| 46 |
+
# Gaming
|
| 47 |
+
"https://feeds.ign.com/ign/all",
|
| 48 |
+
"https://www.eurogamer.net/feed",
|
| 49 |
+
"https://www.pcgamer.com/rss/",
|
| 50 |
+
"https://www.kotaku.com/rss",
|
| 51 |
+
"https://www.gamespot.com/feeds/news/",
|
| 52 |
+
"https://www.polygon.com/rss/index.xml",
|
| 53 |
+
# Movies
|
| 54 |
+
"https://variety.com/feed/",
|
| 55 |
+
"https://www.hollywoodreporter.com/feed/",
|
| 56 |
+
"https://deadline.com/feed/",
|
| 57 |
+
"https://screenrant.com/feed/",
|
| 58 |
+
# Arab World
|
| 59 |
+
"https://www.arabnews.com/rss.xml",
|
| 60 |
+
"https://www.middleeasteye.net/rss",
|
| 61 |
+
"https://www.newarab.com/rss.xml",
|
| 62 |
+
"https://www.france24.com/en/middle-east/rss",
|
| 63 |
+
# Tunisia
|
| 64 |
+
"https://www.tunisiaonlinenews.com/feed/",
|
| 65 |
+
"https://northafricapost.com/feed/",
|
| 66 |
+
"https://www.africanews.com/feed/",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class RSSFeedScraper:
|
| 71 |
+
def __init__(self, config):
|
| 72 |
+
self.config = config
|
| 73 |
+
self.feeds = getattr(config, "rss_feeds", DEFAULT_FEEDS)
|
| 74 |
+
self.session = requests.Session()
|
| 75 |
+
self.session.headers.update({
|
| 76 |
+
"User-Agent": "Mozilla/5.0 (compatible; newsapp/1.0)",
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 80 |
+
seen_titles = set()
|
| 81 |
+
posts = []
|
| 82 |
+
for url in self.feeds:
|
| 83 |
+
logger.info("Fetching RSS: %s", url)
|
| 84 |
+
try:
|
| 85 |
+
if "reddit.com" in url:
|
| 86 |
+
time.sleep(2.5)
|
| 87 |
+
resp = self.session.get(url, timeout=15)
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
feed = feedparser.parse(resp.content)
|
| 90 |
+
for entry in feed.entries[: self.config.posts_per_subreddit]:
|
| 91 |
+
post = self._entry_to_post(entry, url)
|
| 92 |
+
if post and post.title not in seen_titles:
|
| 93 |
+
seen_titles.add(post.title)
|
| 94 |
+
posts.append(post)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.warning("Failed RSS %s: %s", url, exc)
|
| 97 |
+
time.sleep(1.0)
|
| 98 |
+
return posts
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def _clean_url(url: str) -> str:
|
| 102 |
+
parsed = urlparse(url)
|
| 103 |
+
if not parsed.query:
|
| 104 |
+
return url
|
| 105 |
+
params = parse_qs(parsed.query)
|
| 106 |
+
clean = {k: v[0] for k, v in params.items() if k.lower() not in TRACKING_PARAMS}
|
| 107 |
+
if not clean:
|
| 108 |
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
| 109 |
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(clean)}"
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def _extract_image(entry) -> str:
|
| 113 |
+
for key in ("media_content", "media_thumbnail"):
|
| 114 |
+
items = entry.get(key) or []
|
| 115 |
+
for item in items:
|
| 116 |
+
url = item.get("url", "")
|
| 117 |
+
if url:
|
| 118 |
+
return url
|
| 119 |
+
for link in entry.get("links", []):
|
| 120 |
+
if link.get("rel") == "enclosure" and "image" in link.get("type", ""):
|
| 121 |
+
return link.get("href", "")
|
| 122 |
+
return ""
|
| 123 |
+
|
| 124 |
+
def _entry_to_post(self, entry, feed_url: str) -> RedditPost | None:
|
| 125 |
+
title = entry.get("title", "")
|
| 126 |
+
if not title:
|
| 127 |
+
return None
|
| 128 |
+
link = self._clean_url(entry.get("link", ""))
|
| 129 |
+
domain = urlparse(link).netloc or urlparse(feed_url).netloc
|
| 130 |
+
image_url = self._extract_image(entry)
|
| 131 |
+
published, published_iso = self._format_date(entry)
|
| 132 |
+
return RedditPost(
|
| 133 |
+
id=entry.get("id", entry.get("guid", link)),
|
| 134 |
+
title=title,
|
| 135 |
+
url=link,
|
| 136 |
+
subreddit=domain,
|
| 137 |
+
score=0, num_comments=0,
|
| 138 |
+
source_domain=domain,
|
| 139 |
+
image_url=image_url,
|
| 140 |
+
published=published,
|
| 141 |
+
published_iso=published_iso,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def _format_date(entry) -> tuple[str, str]:
|
| 146 |
+
raw = entry.get("published") or entry.get("updated") or ""
|
| 147 |
+
if not raw:
|
| 148 |
+
return ("", "")
|
| 149 |
+
try:
|
| 150 |
+
dt = parsedate_to_datetime(raw)
|
| 151 |
+
display = dt.strftime("%d %b %Y")
|
| 152 |
+
iso = dt.strftime("%Y-%m-%d")
|
| 153 |
+
return (display, iso)
|
| 154 |
+
except Exception:
|
| 155 |
+
if len(raw) >= 10 and raw[4] == "-" and raw[7] == "-":
|
| 156 |
+
iso = raw[:10]
|
| 157 |
+
from datetime import datetime
|
| 158 |
+
try:
|
| 159 |
+
dt = datetime.strptime(iso, "%Y-%m-%d")
|
| 160 |
+
display = dt.strftime("%d %b %Y")
|
| 161 |
+
except Exception:
|
| 162 |
+
display = iso
|
| 163 |
+
return (display, iso)
|
| 164 |
+
return (raw[:16], "")
|
src/src/scraper.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from urllib.parse import urlparse
|
| 3 |
+
|
| 4 |
+
from .models import RedditPost
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RedditScraper:
|
| 10 |
+
def __init__(self, config):
|
| 11 |
+
self.config = config
|
| 12 |
+
self._praw = None
|
| 13 |
+
self._init_praw()
|
| 14 |
+
|
| 15 |
+
def _init_praw(self):
|
| 16 |
+
cid = self.config.reddit_client_id
|
| 17 |
+
secret = self.config.reddit_client_secret
|
| 18 |
+
if not cid or not secret:
|
| 19 |
+
raise ValueError(
|
| 20 |
+
"Reddit API credentials not found.\n"
|
| 21 |
+
" Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env, or\n"
|
| 22 |
+
" use python main.py --demo to run with sample data."
|
| 23 |
+
)
|
| 24 |
+
try:
|
| 25 |
+
import praw
|
| 26 |
+
self._praw = praw.Reddit(
|
| 27 |
+
client_id=cid,
|
| 28 |
+
client_secret=secret,
|
| 29 |
+
user_agent=self.config.reddit_user_agent,
|
| 30 |
+
)
|
| 31 |
+
logger.info("Reddit API initialised via PRAW")
|
| 32 |
+
except ImportError:
|
| 33 |
+
raise ImportError("praw is required. Install with: pip install praw")
|
| 34 |
+
|
| 35 |
+
def fetch_posts(self) -> list[RedditPost]:
|
| 36 |
+
seen = set()
|
| 37 |
+
posts = []
|
| 38 |
+
for sub in self.config.news_subreddits:
|
| 39 |
+
logger.info("Fetching r/%s ...", sub)
|
| 40 |
+
try:
|
| 41 |
+
batch = self._fetch_subreddit(sub)
|
| 42 |
+
for p in batch:
|
| 43 |
+
if p.id not in seen:
|
| 44 |
+
seen.add(p.id)
|
| 45 |
+
posts.append(p)
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
logger.error("Failed to fetch r/%s: %s", sub, exc)
|
| 48 |
+
return posts
|
| 49 |
+
|
| 50 |
+
def _fetch_subreddit(self, subreddit: str) -> list[RedditPost]:
|
| 51 |
+
results = []
|
| 52 |
+
sub = self._praw.subreddit(subreddit)
|
| 53 |
+
for submission in sub.hot(limit=self.config.posts_per_subreddit):
|
| 54 |
+
if submission.is_self:
|
| 55 |
+
continue
|
| 56 |
+
results.append(RedditPost(
|
| 57 |
+
id=submission.id,
|
| 58 |
+
title=submission.title,
|
| 59 |
+
url=submission.url,
|
| 60 |
+
subreddit=subreddit.lower(),
|
| 61 |
+
score=submission.score,
|
| 62 |
+
num_comments=submission.num_comments,
|
| 63 |
+
source_domain=urlparse(submission.url).netloc,
|
| 64 |
+
))
|
| 65 |
+
return results
|
templates/index.html
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.0">
|
| 6 |
+
<title>News Digest</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz@14..32&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
body {
|
| 11 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 12 |
+
background: #0f172a;
|
| 13 |
+
color: #e2e8f0;
|
| 14 |
+
min-height: 100vh;
|
| 15 |
+
}
|
| 16 |
+
.header {
|
| 17 |
+
background: linear-gradient(135deg, #1e293b, #0f172a);
|
| 18 |
+
border-bottom: 1px solid #1e293b;
|
| 19 |
+
padding: 20px 32px;
|
| 20 |
+
position: sticky;
|
| 21 |
+
top: 0;
|
| 22 |
+
z-index: 50;
|
| 23 |
+
backdrop-filter: blur(12px);
|
| 24 |
+
}
|
| 25 |
+
.header-inner {
|
| 26 |
+
max-width: 1400px;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
display: flex;
|
| 29 |
+
align-items: center;
|
| 30 |
+
justify-content: space-between;
|
| 31 |
+
flex-wrap: wrap;
|
| 32 |
+
gap: 12px;
|
| 33 |
+
}
|
| 34 |
+
.header h1 {
|
| 35 |
+
font-size: 22px;
|
| 36 |
+
font-weight: 700;
|
| 37 |
+
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
| 38 |
+
-webkit-background-clip: text;
|
| 39 |
+
-webkit-text-fill-color: transparent;
|
| 40 |
+
}
|
| 41 |
+
.header span { font-size: 13px; color: #64748b; }
|
| 42 |
+
.status { color: #64748b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
| 43 |
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; display: inline-block; }
|
| 44 |
+
.container { max-width: 1400px; margin: 0 auto; padding: 20px 32px; }
|
| 45 |
+
|
| 46 |
+
/* Tabs */
|
| 47 |
+
.tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid #1e293b; overflow-x: auto; }
|
| 48 |
+
.tab {
|
| 49 |
+
padding: 10px 20px;
|
| 50 |
+
border: none;
|
| 51 |
+
background: none;
|
| 52 |
+
color: #64748b;
|
| 53 |
+
font-size: 14px;
|
| 54 |
+
font-weight: 500;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
border-bottom: 2px solid transparent;
|
| 57 |
+
transition: all .15s;
|
| 58 |
+
white-space: nowrap;
|
| 59 |
+
}
|
| 60 |
+
.tab:hover { color: #94a3b8; }
|
| 61 |
+
.tab.active { color: #60a5fa; border-bottom-color: #60a5fa; }
|
| 62 |
+
.tab-count { font-size: 11px; color: #475569; margin-left: 4px; }
|
| 63 |
+
|
| 64 |
+
.tab-content { display: none; }
|
| 65 |
+
.tab-content.active { display: block; }
|
| 66 |
+
|
| 67 |
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
| 68 |
+
.card {
|
| 69 |
+
background: #1e293b;
|
| 70 |
+
border-radius: 14px;
|
| 71 |
+
overflow: hidden;
|
| 72 |
+
border: 1px solid #334155;
|
| 73 |
+
transition: transform .2s, box-shadow .2s;
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
}
|
| 77 |
+
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.3); }
|
| 78 |
+
.card-image {
|
| 79 |
+
width: 100%;
|
| 80 |
+
height: 180px;
|
| 81 |
+
object-fit: cover;
|
| 82 |
+
background: #334155;
|
| 83 |
+
display: block;
|
| 84 |
+
}
|
| 85 |
+
.card-image-placeholder {
|
| 86 |
+
width: 100%;
|
| 87 |
+
height: 180px;
|
| 88 |
+
background: linear-gradient(135deg, #334155, #1e293b);
|
| 89 |
+
display: flex;
|
| 90 |
+
align-items: center;
|
| 91 |
+
justify-content: center;
|
| 92 |
+
font-size: 40px;
|
| 93 |
+
color: #475569;
|
| 94 |
+
}
|
| 95 |
+
.card-body { padding: 14px 18px 18px; flex: 1; display: flex; flex-direction: column; }
|
| 96 |
+
.card-rank {
|
| 97 |
+
font-size: 10px;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
color: #475569;
|
| 100 |
+
letter-spacing: .05em;
|
| 101 |
+
margin-bottom: 4px;
|
| 102 |
+
}
|
| 103 |
+
.card-topic {
|
| 104 |
+
font-size: 10px;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
text-transform: uppercase;
|
| 107 |
+
letter-spacing: .08em;
|
| 108 |
+
color: #60a5fa;
|
| 109 |
+
margin-bottom: 6px;
|
| 110 |
+
}
|
| 111 |
+
.card-title {
|
| 112 |
+
font-size: 15px;
|
| 113 |
+
font-weight: 600;
|
| 114 |
+
line-height: 1.4;
|
| 115 |
+
margin-bottom: 6px;
|
| 116 |
+
display: -webkit-box;
|
| 117 |
+
-webkit-line-clamp: 2;
|
| 118 |
+
-webkit-box-orient: vertical;
|
| 119 |
+
overflow: hidden;
|
| 120 |
+
}
|
| 121 |
+
.card-summary {
|
| 122 |
+
font-size: 13px;
|
| 123 |
+
color: #94a3b8;
|
| 124 |
+
line-height: 1.5;
|
| 125 |
+
margin-bottom: 10px;
|
| 126 |
+
display: -webkit-box;
|
| 127 |
+
-webkit-line-clamp: 2;
|
| 128 |
+
-webkit-box-orient: vertical;
|
| 129 |
+
overflow: hidden;
|
| 130 |
+
flex: 1;
|
| 131 |
+
}
|
| 132 |
+
.card-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #64748b; margin-bottom: 10px; flex-wrap: wrap; }
|
| 133 |
+
.card-meta .source { color: #94a3b8; }
|
| 134 |
+
.score-bar-wrap { height: 3px; background: #334155; border-radius: 3px; margin-bottom: 3px; }
|
| 135 |
+
.score-bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #60a5fa, #a78bfa); transition: width .5s; }
|
| 136 |
+
.score-label { font-size: 10px; color: #64748b; display: flex; justify-content: space-between; }
|
| 137 |
+
.card-actions { display: flex; gap: 6px; margin-top: 6px; }
|
| 138 |
+
.btn {
|
| 139 |
+
display: inline-flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
gap: 4px;
|
| 142 |
+
padding: 6px 14px;
|
| 143 |
+
border-radius: 8px;
|
| 144 |
+
font-size: 12px;
|
| 145 |
+
font-weight: 500;
|
| 146 |
+
text-decoration: none;
|
| 147 |
+
transition: background .15s;
|
| 148 |
+
}
|
| 149 |
+
.btn-primary { background: #3b82f6; color: #fff; }
|
| 150 |
+
.btn-primary:hover { background: #2563eb; }
|
| 151 |
+
.btn-secondary { background: #334155; color: #94a3b8; }
|
| 152 |
+
.btn-secondary:hover { background: #475569; color: #e2e8f0; }
|
| 153 |
+
.card-date {
|
| 154 |
+
margin-left: auto;
|
| 155 |
+
font-size: 12px;
|
| 156 |
+
font-weight: 700;
|
| 157 |
+
color: #c4b5fd;
|
| 158 |
+
background: rgba(167, 139, 250, 0.12);
|
| 159 |
+
padding: 3px 10px;
|
| 160 |
+
border-radius: 6px;
|
| 161 |
+
letter-spacing: .03em;
|
| 162 |
+
border: 1px solid rgba(167, 139, 250, 0.2);
|
| 163 |
+
}
|
| 164 |
+
.card-topics { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
| 165 |
+
.topic-tag { font-size: 10px; padding: 2px 7px; border-radius: 5px; background: #334155; color: #94a3b8; }
|
| 166 |
+
|
| 167 |
+
.date-group { margin-bottom: 16px; }
|
| 168 |
+
.date-header {
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
font-weight: 700;
|
| 171 |
+
color: #818cf8;
|
| 172 |
+
text-transform: uppercase;
|
| 173 |
+
letter-spacing: .08em;
|
| 174 |
+
margin-bottom: 10px;
|
| 175 |
+
padding-bottom: 6px;
|
| 176 |
+
border-bottom: 1px solid #334155;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.empty { text-align: center; padding: 60px 20px; color: #64748b; }
|
| 180 |
+
.empty h2 { font-size: 18px; margin-bottom: 6px; }
|
| 181 |
+
|
| 182 |
+
@media (max-width: 640px) {
|
| 183 |
+
.header { padding: 14px; }
|
| 184 |
+
.container { padding: 14px; }
|
| 185 |
+
.grid { grid-template-columns: 1fr; }
|
| 186 |
+
}
|
| 187 |
+
</style>
|
| 188 |
+
</head>
|
| 189 |
+
<body>
|
| 190 |
+
<div class="header">
|
| 191 |
+
<div class="header-inner">
|
| 192 |
+
<div>
|
| 193 |
+
<h1>News Digest</h1>
|
| 194 |
+
<span>{{ total }} stories across {{ categories|length }} topics</span>
|
| 195 |
+
</div>
|
| 196 |
+
<div class="status">
|
| 197 |
+
<span class="status-dot"></span>
|
| 198 |
+
{{ status }}
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="container">
|
| 204 |
+
{% if total == 0 %}
|
| 205 |
+
<div class="empty">
|
| 206 |
+
<h2>No news yet</h2>
|
| 207 |
+
<p>Run <code>python webapp.py</code> to fetch and analyze articles</p>
|
| 208 |
+
</div>
|
| 209 |
+
{% else %}
|
| 210 |
+
<div class="tabs" id="tabs">
|
| 211 |
+
{% for cat in categories %}
|
| 212 |
+
<button class="tab {% if loop.first %}active{% endif %}" data-tab="{{ cat | replace(' ', '_') | replace('/', '_') }}">
|
| 213 |
+
{{ cat }} <span class="tab-count">({{ tab_counts[cat] }})</span>
|
| 214 |
+
</button>
|
| 215 |
+
{% endfor %}
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{% for cat in categories %}
|
| 219 |
+
<div class="tab-content {% if loop.first %}active{% endif %}" id="tab-{{ cat | replace(' ', '_') | replace('/', '_') }}">
|
| 220 |
+
{% set date_groups = grouped_by_date[cat] %}
|
| 221 |
+
{% if date_groups %}
|
| 222 |
+
{% for group in date_groups %}
|
| 223 |
+
<div class="date-group">
|
| 224 |
+
<div class="date-header">{{ group.date }}</div>
|
| 225 |
+
<div class="grid">
|
| 226 |
+
{% for c in group['items'] %}
|
| 227 |
+
<div class="card">
|
| 228 |
+
{% if c.image_url %}
|
| 229 |
+
<img class="card-image" src="{{ c.image_url }}" alt="" loading="lazy" onerror="this.style.display='none'">
|
| 230 |
+
{% else %}
|
| 231 |
+
<div class="card-image-placeholder">📰</div>
|
| 232 |
+
{% endif %}
|
| 233 |
+
<div class="card-body">
|
| 234 |
+
<div class="card-rank">#{{ loop.index }}</div>
|
| 235 |
+
<div class="card-topic">{{ c.topic }}</div>
|
| 236 |
+
<div class="card-title">{{ c.articles[0].title }}</div>
|
| 237 |
+
{% if c.articles[0].summary %}
|
| 238 |
+
<div class="card-summary">{{ c.articles[0].summary[:160] }}{% if c.articles[0].summary|length > 160 %}…{% endif %}</div>
|
| 239 |
+
{% endif %}
|
| 240 |
+
<div class="card-topics">
|
| 241 |
+
{% for t in c.articles[0].topics[:3] %}
|
| 242 |
+
<span class="topic-tag">{{ t }}</span>
|
| 243 |
+
{% endfor %}
|
| 244 |
+
</div>
|
| 245 |
+
<div class="card-meta">
|
| 246 |
+
<span class="source">{{ c.articles[0].domain }}</span>
|
| 247 |
+
<span>🛡️ {{ c.trust }}</span>
|
| 248 |
+
{% if c.coverage > 1 %}<span>📰 {{ c.coverage }}</span>{% endif %}
|
| 249 |
+
</div>
|
| 250 |
+
<div class="score-bar-wrap">
|
| 251 |
+
<div class="score-bar-fill" style="width:{{ c.score * 100 }}%"></div>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="score-label">
|
| 254 |
+
<span>Relevance</span>
|
| 255 |
+
<span>{{ c.score }}</span>
|
| 256 |
+
</div>
|
| 257 |
+
<div class="card-actions">
|
| 258 |
+
<a href="{{ c.top_post_url }}" class="btn btn-primary" target="_blank">Read</a>
|
| 259 |
+
<a href="{{ c.top_post_url }}" class="btn btn-secondary" target="_blank">Open</a>
|
| 260 |
+
{% if c.articles[0].published %}
|
| 261 |
+
<span class="card-date">{{ c.articles[0].published }}</span>
|
| 262 |
+
{% endif %}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
{% endfor %}
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
{% endfor %}
|
| 270 |
+
{% else %}
|
| 271 |
+
<div class="empty"><h2>No {{ cat }} stories found</h2></div>
|
| 272 |
+
{% endif %}
|
| 273 |
+
</div>
|
| 274 |
+
{% endfor %}
|
| 275 |
+
{% endif %}
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<script>
|
| 279 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 280 |
+
tab.addEventListener('click', () => {
|
| 281 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 282 |
+
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
| 283 |
+
tab.classList.add('active');
|
| 284 |
+
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
| 285 |
+
});
|
| 286 |
+
});
|
| 287 |
+
</script>
|
| 288 |
+
</body>
|
| 289 |
+
</html>
|
templates/templates/index.html
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.0">
|
| 6 |
+
<title>News Digest</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz@14..32&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
body {
|
| 11 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 12 |
+
background: #0f172a;
|
| 13 |
+
color: #e2e8f0;
|
| 14 |
+
min-height: 100vh;
|
| 15 |
+
}
|
| 16 |
+
.header {
|
| 17 |
+
background: linear-gradient(135deg, #1e293b, #0f172a);
|
| 18 |
+
border-bottom: 1px solid #1e293b;
|
| 19 |
+
padding: 20px 32px;
|
| 20 |
+
position: sticky;
|
| 21 |
+
top: 0;
|
| 22 |
+
z-index: 50;
|
| 23 |
+
backdrop-filter: blur(12px);
|
| 24 |
+
}
|
| 25 |
+
.header-inner {
|
| 26 |
+
max-width: 1400px;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
display: flex;
|
| 29 |
+
align-items: center;
|
| 30 |
+
justify-content: space-between;
|
| 31 |
+
flex-wrap: wrap;
|
| 32 |
+
gap: 12px;
|
| 33 |
+
}
|
| 34 |
+
.header h1 {
|
| 35 |
+
font-size: 22px;
|
| 36 |
+
font-weight: 700;
|
| 37 |
+
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
| 38 |
+
-webkit-background-clip: text;
|
| 39 |
+
-webkit-text-fill-color: transparent;
|
| 40 |
+
}
|
| 41 |
+
.header span { font-size: 13px; color: #64748b; }
|
| 42 |
+
.status { color: #64748b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
| 43 |
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; display: inline-block; }
|
| 44 |
+
.container { max-width: 1400px; margin: 0 auto; padding: 20px 32px; }
|
| 45 |
+
|
| 46 |
+
/* Tabs */
|
| 47 |
+
.tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid #1e293b; overflow-x: auto; }
|
| 48 |
+
.tab {
|
| 49 |
+
padding: 10px 20px;
|
| 50 |
+
border: none;
|
| 51 |
+
background: none;
|
| 52 |
+
color: #64748b;
|
| 53 |
+
font-size: 14px;
|
| 54 |
+
font-weight: 500;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
border-bottom: 2px solid transparent;
|
| 57 |
+
transition: all .15s;
|
| 58 |
+
white-space: nowrap;
|
| 59 |
+
}
|
| 60 |
+
.tab:hover { color: #94a3b8; }
|
| 61 |
+
.tab.active { color: #60a5fa; border-bottom-color: #60a5fa; }
|
| 62 |
+
.tab-count { font-size: 11px; color: #475569; margin-left: 4px; }
|
| 63 |
+
|
| 64 |
+
.tab-content { display: none; }
|
| 65 |
+
.tab-content.active { display: block; }
|
| 66 |
+
|
| 67 |
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
| 68 |
+
.card {
|
| 69 |
+
background: #1e293b;
|
| 70 |
+
border-radius: 14px;
|
| 71 |
+
overflow: hidden;
|
| 72 |
+
border: 1px solid #334155;
|
| 73 |
+
transition: transform .2s, box-shadow .2s;
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
}
|
| 77 |
+
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.3); }
|
| 78 |
+
.card-image {
|
| 79 |
+
width: 100%;
|
| 80 |
+
height: 180px;
|
| 81 |
+
object-fit: cover;
|
| 82 |
+
background: #334155;
|
| 83 |
+
display: block;
|
| 84 |
+
}
|
| 85 |
+
.card-image-placeholder {
|
| 86 |
+
width: 100%;
|
| 87 |
+
height: 180px;
|
| 88 |
+
background: linear-gradient(135deg, #334155, #1e293b);
|
| 89 |
+
display: flex;
|
| 90 |
+
align-items: center;
|
| 91 |
+
justify-content: center;
|
| 92 |
+
font-size: 40px;
|
| 93 |
+
color: #475569;
|
| 94 |
+
}
|
| 95 |
+
.card-body { padding: 14px 18px 18px; flex: 1; display: flex; flex-direction: column; }
|
| 96 |
+
.card-rank {
|
| 97 |
+
font-size: 10px;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
color: #475569;
|
| 100 |
+
letter-spacing: .05em;
|
| 101 |
+
margin-bottom: 4px;
|
| 102 |
+
}
|
| 103 |
+
.card-topic {
|
| 104 |
+
font-size: 10px;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
text-transform: uppercase;
|
| 107 |
+
letter-spacing: .08em;
|
| 108 |
+
color: #60a5fa;
|
| 109 |
+
margin-bottom: 6px;
|
| 110 |
+
}
|
| 111 |
+
.card-title {
|
| 112 |
+
font-size: 15px;
|
| 113 |
+
font-weight: 600;
|
| 114 |
+
line-height: 1.4;
|
| 115 |
+
margin-bottom: 6px;
|
| 116 |
+
display: -webkit-box;
|
| 117 |
+
-webkit-line-clamp: 2;
|
| 118 |
+
-webkit-box-orient: vertical;
|
| 119 |
+
overflow: hidden;
|
| 120 |
+
}
|
| 121 |
+
.card-summary {
|
| 122 |
+
font-size: 13px;
|
| 123 |
+
color: #94a3b8;
|
| 124 |
+
line-height: 1.5;
|
| 125 |
+
margin-bottom: 10px;
|
| 126 |
+
display: -webkit-box;
|
| 127 |
+
-webkit-line-clamp: 2;
|
| 128 |
+
-webkit-box-orient: vertical;
|
| 129 |
+
overflow: hidden;
|
| 130 |
+
flex: 1;
|
| 131 |
+
}
|
| 132 |
+
.card-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #64748b; margin-bottom: 10px; flex-wrap: wrap; }
|
| 133 |
+
.card-meta .source { color: #94a3b8; }
|
| 134 |
+
.score-bar-wrap { height: 3px; background: #334155; border-radius: 3px; margin-bottom: 3px; }
|
| 135 |
+
.score-bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #60a5fa, #a78bfa); transition: width .5s; }
|
| 136 |
+
.score-label { font-size: 10px; color: #64748b; display: flex; justify-content: space-between; }
|
| 137 |
+
.card-actions { display: flex; gap: 6px; margin-top: 6px; }
|
| 138 |
+
.btn {
|
| 139 |
+
display: inline-flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
gap: 4px;
|
| 142 |
+
padding: 6px 14px;
|
| 143 |
+
border-radius: 8px;
|
| 144 |
+
font-size: 12px;
|
| 145 |
+
font-weight: 500;
|
| 146 |
+
text-decoration: none;
|
| 147 |
+
transition: background .15s;
|
| 148 |
+
}
|
| 149 |
+
.btn-primary { background: #3b82f6; color: #fff; }
|
| 150 |
+
.btn-primary:hover { background: #2563eb; }
|
| 151 |
+
.btn-secondary { background: #334155; color: #94a3b8; }
|
| 152 |
+
.btn-secondary:hover { background: #475569; color: #e2e8f0; }
|
| 153 |
+
.card-date {
|
| 154 |
+
margin-left: auto;
|
| 155 |
+
font-size: 12px;
|
| 156 |
+
font-weight: 700;
|
| 157 |
+
color: #c4b5fd;
|
| 158 |
+
background: rgba(167, 139, 250, 0.12);
|
| 159 |
+
padding: 3px 10px;
|
| 160 |
+
border-radius: 6px;
|
| 161 |
+
letter-spacing: .03em;
|
| 162 |
+
border: 1px solid rgba(167, 139, 250, 0.2);
|
| 163 |
+
}
|
| 164 |
+
.card-topics { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
| 165 |
+
.topic-tag { font-size: 10px; padding: 2px 7px; border-radius: 5px; background: #334155; color: #94a3b8; }
|
| 166 |
+
|
| 167 |
+
.date-group { margin-bottom: 16px; }
|
| 168 |
+
.date-header {
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
font-weight: 700;
|
| 171 |
+
color: #818cf8;
|
| 172 |
+
text-transform: uppercase;
|
| 173 |
+
letter-spacing: .08em;
|
| 174 |
+
margin-bottom: 10px;
|
| 175 |
+
padding-bottom: 6px;
|
| 176 |
+
border-bottom: 1px solid #334155;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.empty { text-align: center; padding: 60px 20px; color: #64748b; }
|
| 180 |
+
.empty h2 { font-size: 18px; margin-bottom: 6px; }
|
| 181 |
+
|
| 182 |
+
@media (max-width: 640px) {
|
| 183 |
+
.header { padding: 14px; }
|
| 184 |
+
.container { padding: 14px; }
|
| 185 |
+
.grid { grid-template-columns: 1fr; }
|
| 186 |
+
}
|
| 187 |
+
</style>
|
| 188 |
+
</head>
|
| 189 |
+
<body>
|
| 190 |
+
<div class="header">
|
| 191 |
+
<div class="header-inner">
|
| 192 |
+
<div>
|
| 193 |
+
<h1>News Digest</h1>
|
| 194 |
+
<span>{{ total }} stories across {{ categories|length }} topics</span>
|
| 195 |
+
</div>
|
| 196 |
+
<div class="status">
|
| 197 |
+
<span class="status-dot"></span>
|
| 198 |
+
{{ status }}
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="container">
|
| 204 |
+
{% if total == 0 %}
|
| 205 |
+
<div class="empty">
|
| 206 |
+
<h2>No news yet</h2>
|
| 207 |
+
<p>Run <code>python webapp.py</code> to fetch and analyze articles</p>
|
| 208 |
+
</div>
|
| 209 |
+
{% else %}
|
| 210 |
+
<div class="tabs" id="tabs">
|
| 211 |
+
{% for cat in categories %}
|
| 212 |
+
<button class="tab {% if loop.first %}active{% endif %}" data-tab="{{ cat | replace(' ', '_') | replace('/', '_') }}">
|
| 213 |
+
{{ cat }} <span class="tab-count">({{ tab_counts[cat] }})</span>
|
| 214 |
+
</button>
|
| 215 |
+
{% endfor %}
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{% for cat in categories %}
|
| 219 |
+
<div class="tab-content {% if loop.first %}active{% endif %}" id="tab-{{ cat | replace(' ', '_') | replace('/', '_') }}">
|
| 220 |
+
{% set date_groups = grouped_by_date[cat] %}
|
| 221 |
+
{% if date_groups %}
|
| 222 |
+
{% for group in date_groups %}
|
| 223 |
+
<div class="date-group">
|
| 224 |
+
<div class="date-header">{{ group.date }}</div>
|
| 225 |
+
<div class="grid">
|
| 226 |
+
{% for c in group['items'] %}
|
| 227 |
+
<div class="card">
|
| 228 |
+
{% if c.image_url %}
|
| 229 |
+
<img class="card-image" src="{{ c.image_url }}" alt="" loading="lazy" onerror="this.style.display='none'">
|
| 230 |
+
{% else %}
|
| 231 |
+
<div class="card-image-placeholder">📰</div>
|
| 232 |
+
{% endif %}
|
| 233 |
+
<div class="card-body">
|
| 234 |
+
<div class="card-rank">#{{ loop.index }}</div>
|
| 235 |
+
<div class="card-topic">{{ c.topic }}</div>
|
| 236 |
+
<div class="card-title">{{ c.articles[0].title }}</div>
|
| 237 |
+
{% if c.articles[0].summary %}
|
| 238 |
+
<div class="card-summary">{{ c.articles[0].summary[:160] }}{% if c.articles[0].summary|length > 160 %}…{% endif %}</div>
|
| 239 |
+
{% endif %}
|
| 240 |
+
<div class="card-topics">
|
| 241 |
+
{% for t in c.articles[0].topics[:3] %}
|
| 242 |
+
<span class="topic-tag">{{ t }}</span>
|
| 243 |
+
{% endfor %}
|
| 244 |
+
</div>
|
| 245 |
+
<div class="card-meta">
|
| 246 |
+
<span class="source">{{ c.articles[0].domain }}</span>
|
| 247 |
+
<span>🛡️ {{ c.trust }}</span>
|
| 248 |
+
{% if c.coverage > 1 %}<span>📰 {{ c.coverage }}</span>{% endif %}
|
| 249 |
+
</div>
|
| 250 |
+
<div class="score-bar-wrap">
|
| 251 |
+
<div class="score-bar-fill" style="width:{{ c.score * 100 }}%"></div>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="score-label">
|
| 254 |
+
<span>Relevance</span>
|
| 255 |
+
<span>{{ c.score }}</span>
|
| 256 |
+
</div>
|
| 257 |
+
<div class="card-actions">
|
| 258 |
+
<a href="{{ c.top_post_url }}" class="btn btn-primary" target="_blank">Read</a>
|
| 259 |
+
<a href="{{ c.top_post_url }}" class="btn btn-secondary" target="_blank">Open</a>
|
| 260 |
+
{% if c.articles[0].published %}
|
| 261 |
+
<span class="card-date">{{ c.articles[0].published }}</span>
|
| 262 |
+
{% endif %}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
{% endfor %}
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
{% endfor %}
|
| 270 |
+
{% else %}
|
| 271 |
+
<div class="empty"><h2>No {{ cat }} stories found</h2></div>
|
| 272 |
+
{% endif %}
|
| 273 |
+
</div>
|
| 274 |
+
{% endfor %}
|
| 275 |
+
{% endif %}
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<script>
|
| 279 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 280 |
+
tab.addEventListener('click', () => {
|
| 281 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 282 |
+
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
| 283 |
+
tab.classList.add('active');
|
| 284 |
+
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
| 285 |
+
});
|
| 286 |
+
});
|
| 287 |
+
</script>
|
| 288 |
+
</body>
|
| 289 |
+
</html>
|
webapp.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import threading
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from flask import Flask, jsonify, render_template, request
|
| 10 |
+
from flask_cors import CORS
|
| 11 |
+
|
| 12 |
+
from config import Config
|
| 13 |
+
from main import run_pipeline
|
| 14 |
+
from src.models import NewsItem
|
| 15 |
+
from src.rss_feed_scraper import RSSFeedScraper
|
| 16 |
+
|
| 17 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname).1s %(message)s", stream=sys.stderr)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
cfg = Config()
|
| 21 |
+
|
| 22 |
+
app = Flask(__name__)
|
| 23 |
+
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
| 24 |
+
CORS(app, origins=cfg.cors_origins.split(",") if cfg.cors_origins != "*" else "*")
|
| 25 |
+
|
| 26 |
+
cache_lock = threading.Lock()
|
| 27 |
+
|
| 28 |
+
CACHE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache_data.json")
|
| 29 |
+
|
| 30 |
+
CATEGORIES = ["Geopolitical", "World Health", "Tech", "Cybersecurity", "Funny/Weird", "Gaming", "Movies", "Arab World", "Tunisia"]
|
| 31 |
+
|
| 32 |
+
cached_by_cat: dict[str, list[dict]] = defaultdict(list)
|
| 33 |
+
cached_results: list[dict] = []
|
| 34 |
+
cached_status = "starting"
|
| 35 |
+
cached_last_refresh = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _cluster_to_html_dict(c):
|
| 39 |
+
category = "General"
|
| 40 |
+
if c.articles and c.articles[0].analysis:
|
| 41 |
+
cat = c.articles[0].analysis.category
|
| 42 |
+
category = cat if cat in CATEGORIES else "General"
|
| 43 |
+
return {
|
| 44 |
+
"category": category,
|
| 45 |
+
"topic": c.topic,
|
| 46 |
+
"score": round(c.final_score, 2),
|
| 47 |
+
"trust": f"{c.avg_trustworthiness:.0%}",
|
| 48 |
+
"coverage": c.total_coverage,
|
| 49 |
+
"image_url": c.image_url,
|
| 50 |
+
"top_post_url": c.top_post_url,
|
| 51 |
+
"articles": [
|
| 52 |
+
{
|
| 53 |
+
"title": a.post.title,
|
| 54 |
+
"domain": a.article.source_domain if a.article else "",
|
| 55 |
+
"summary": a.analysis.summary if a.analysis else "",
|
| 56 |
+
"topics": a.analysis.topics if a.analysis else [],
|
| 57 |
+
"trust": f"{a.analysis.trustworthiness_score:.0%}" if a.analysis else "",
|
| 58 |
+
"score": a.post.score,
|
| 59 |
+
"comments": a.post.num_comments,
|
| 60 |
+
"url": a.post.url,
|
| 61 |
+
"image": a.article.image_url if a.article else a.post.image_url,
|
| 62 |
+
"published": a.post.published,
|
| 63 |
+
"published_iso": a.post.published_iso,
|
| 64 |
+
}
|
| 65 |
+
for a in c.articles[:5]
|
| 66 |
+
],
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _cluster_to_api_dict(c):
|
| 71 |
+
cat = "General"
|
| 72 |
+
if c.articles and c.articles[0].analysis:
|
| 73 |
+
ca = c.articles[0].analysis.category
|
| 74 |
+
cat = ca if ca in CATEGORIES else "General"
|
| 75 |
+
return {
|
| 76 |
+
"id": f"cluster-{id(c)}",
|
| 77 |
+
"topic": c.topic,
|
| 78 |
+
"category": cat,
|
| 79 |
+
"final_score": round(c.final_score, 2),
|
| 80 |
+
"avg_trustworthiness": round(c.avg_trustworthiness, 2),
|
| 81 |
+
"total_coverage": c.total_coverage,
|
| 82 |
+
"image_url": c.image_url,
|
| 83 |
+
"top_post_url": c.top_post_url,
|
| 84 |
+
"articles": [
|
| 85 |
+
{
|
| 86 |
+
"title": a.post.title,
|
| 87 |
+
"url": a.post.url,
|
| 88 |
+
"domain": a.article.source_domain if a.article else "",
|
| 89 |
+
"summary": a.analysis.summary if a.analysis else "",
|
| 90 |
+
"topics": a.analysis.topics if a.analysis else [],
|
| 91 |
+
"trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
|
| 92 |
+
"score": a.post.score,
|
| 93 |
+
"comments": a.post.num_comments,
|
| 94 |
+
"image": a.article.image_url if a.article else a.post.image_url,
|
| 95 |
+
"published": a.post.published,
|
| 96 |
+
"published_iso": a.post.published_iso,
|
| 97 |
+
}
|
| 98 |
+
for a in c.articles[:5]
|
| 99 |
+
],
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def refresh_data():
|
| 104 |
+
global cached_by_cat, cached_results, cached_status, cached_last_refresh
|
| 105 |
+
with cache_lock:
|
| 106 |
+
cached_status = "running"
|
| 107 |
+
try:
|
| 108 |
+
scraper = RSSFeedScraper(cfg)
|
| 109 |
+
posts = scraper.fetch_posts()
|
| 110 |
+
items = [NewsItem(post=p) for p in posts]
|
| 111 |
+
new_clusters = run_pipeline(items, cfg)
|
| 112 |
+
html_dicts = [_cluster_to_html_dict(c) for c in new_clusters]
|
| 113 |
+
api_dicts = [_cluster_to_api_dict(c) for c in new_clusters]
|
| 114 |
+
by_cat: dict[str, list[dict]] = defaultdict(list)
|
| 115 |
+
for d in html_dicts:
|
| 116 |
+
by_cat[d["category"]].append(d)
|
| 117 |
+
with cache_lock:
|
| 118 |
+
cached_by_cat = by_cat
|
| 119 |
+
cached_results = api_dicts
|
| 120 |
+
cached_last_refresh = datetime.now(timezone.utc)
|
| 121 |
+
cached_status = f"ok — {len(new_clusters)} clusters from {len(items)} articles"
|
| 122 |
+
_save_cache(by_cat, api_dicts, cached_status, cached_last_refresh)
|
| 123 |
+
logger.info("Refresh complete: %s", cached_status)
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
with cache_lock:
|
| 126 |
+
cached_status = f"error: {exc}"
|
| 127 |
+
logger.error("Pipeline failed: %s", exc)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _save_cache(by_cat, results, status, last_refresh):
|
| 131 |
+
try:
|
| 132 |
+
data = {
|
| 133 |
+
"by_cat": {k: v for k, v in by_cat.items()},
|
| 134 |
+
"results": results,
|
| 135 |
+
"status": status,
|
| 136 |
+
"last_refresh": last_refresh.isoformat() if last_refresh else None,
|
| 137 |
+
}
|
| 138 |
+
with open(CACHE_FILE, "w") as f:
|
| 139 |
+
json.dump(data, f)
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
logger.warning("Failed to write cache file: %s", exc)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _load_cache():
|
| 145 |
+
try:
|
| 146 |
+
with open(CACHE_FILE) as f:
|
| 147 |
+
data = json.load(f)
|
| 148 |
+
return data
|
| 149 |
+
except (FileNotFoundError, json.JSONDecodeError):
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---- Web UI ----
|
| 154 |
+
|
| 155 |
+
@app.route("/")
|
| 156 |
+
def index():
|
| 157 |
+
with cache_lock:
|
| 158 |
+
bc = cached_by_cat
|
| 159 |
+
status = cached_status
|
| 160 |
+
grouped_by_date = {}
|
| 161 |
+
for cat in CATEGORIES:
|
| 162 |
+
items = bc.get(cat, [])
|
| 163 |
+
items.sort(key=lambda x: (x["articles"][0]["published_iso"] or "", x["score"]), reverse=True)
|
| 164 |
+
date_groups = []
|
| 165 |
+
seen_date = None
|
| 166 |
+
for item in items:
|
| 167 |
+
d = item["articles"][0]["published"] or "Unknown"
|
| 168 |
+
if d != seen_date:
|
| 169 |
+
date_groups.append({"date": d, "items": []})
|
| 170 |
+
seen_date = d
|
| 171 |
+
date_groups[-1]["items"].append(item)
|
| 172 |
+
grouped_by_date[cat] = date_groups
|
| 173 |
+
|
| 174 |
+
total = sum(len(g["items"]) for groups in grouped_by_date.values() for g in groups)
|
| 175 |
+
tab_counts = {cat: sum(len(g["items"]) for g in grouped_by_date[cat]) for cat in CATEGORIES}
|
| 176 |
+
return render_template("index.html", grouped_by_date=grouped_by_date, categories=CATEGORIES, status=status, total=total, tab_counts=tab_counts)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ---- REST API ----
|
| 180 |
+
|
| 181 |
+
@app.route("/api/v1/status")
|
| 182 |
+
def api_status():
|
| 183 |
+
with cache_lock:
|
| 184 |
+
status = cached_status
|
| 185 |
+
lr = cached_last_refresh
|
| 186 |
+
n_clusters = len(cached_results)
|
| 187 |
+
n_articles = sum(c["total_coverage"] for c in cached_results)
|
| 188 |
+
return jsonify({
|
| 189 |
+
"status": "ok",
|
| 190 |
+
"clusters": n_clusters,
|
| 191 |
+
"total_articles": n_articles,
|
| 192 |
+
"last_refresh": lr.isoformat() if lr else None,
|
| 193 |
+
"pipeline_status": status,
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.route("/api/v1/posts")
|
| 198 |
+
def api_posts():
|
| 199 |
+
category = request.args.get("category")
|
| 200 |
+
topic = request.args.get("topic")
|
| 201 |
+
limit = request.args.get("limit", type=int)
|
| 202 |
+
min_score = request.args.get("min_score", type=float)
|
| 203 |
+
|
| 204 |
+
with cache_lock:
|
| 205 |
+
all_results = list(cached_results)
|
| 206 |
+
|
| 207 |
+
filtered = all_results
|
| 208 |
+
if category:
|
| 209 |
+
filtered = [r for r in filtered if r["category"].lower() == category.lower()]
|
| 210 |
+
if topic:
|
| 211 |
+
filtered = [r for r in filtered if topic.lower() in [t.lower() for t in r.get("articles", [])[:1]]]
|
| 212 |
+
if min_score is not None:
|
| 213 |
+
filtered = [r for r in filtered if r["final_score"] >= min_score]
|
| 214 |
+
if limit:
|
| 215 |
+
filtered = filtered[:limit]
|
| 216 |
+
|
| 217 |
+
return jsonify({"clusters": filtered, "meta": {"total": len(filtered)}})
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@app.route("/api/v1/categories")
|
| 221 |
+
def api_categories():
|
| 222 |
+
return jsonify({"categories": CATEGORIES})
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@app.route("/api/v1/refresh", methods=["POST"])
|
| 226 |
+
def api_refresh():
|
| 227 |
+
threading.Thread(target=refresh_data, daemon=True).start()
|
| 228 |
+
return jsonify({"status": "accepted", "message": "refresh started"}), 202
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@app.route("/health")
|
| 232 |
+
def health():
|
| 233 |
+
with cache_lock:
|
| 234 |
+
ok = "error" not in cached_status
|
| 235 |
+
if ok:
|
| 236 |
+
return jsonify({"status": "healthy"}), 200
|
| 237 |
+
return jsonify({"status": "unhealthy", "message": cached_status}), 503
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---- Load cached data on startup (for WSGI) ----
|
| 241 |
+
|
| 242 |
+
_cache_data = _load_cache()
|
| 243 |
+
if _cache_data:
|
| 244 |
+
cached_by_cat = defaultdict(list, _cache_data.get("by_cat", {}))
|
| 245 |
+
cached_results = _cache_data.get("results", [])
|
| 246 |
+
cached_status = _cache_data.get("status", "idle")
|
| 247 |
+
lr = _cache_data.get("last_refresh")
|
| 248 |
+
if lr:
|
| 249 |
+
try:
|
| 250 |
+
cached_last_refresh = datetime.fromisoformat(lr)
|
| 251 |
+
except Exception:
|
| 252 |
+
pass
|
| 253 |
+
logger.info("Loaded cached data: %s", cached_status)
|
| 254 |
+
|
| 255 |
+
# ---- Scheduled refresh for long-running processes ----
|
| 256 |
+
|
| 257 |
+
def _start_scheduler():
|
| 258 |
+
try:
|
| 259 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 260 |
+
scheduler = BackgroundScheduler(daemon=True)
|
| 261 |
+
scheduler.add_job(
|
| 262 |
+
refresh_data,
|
| 263 |
+
trigger="cron",
|
| 264 |
+
hour=cfg.refresh_hour,
|
| 265 |
+
minute=cfg.refresh_minute,
|
| 266 |
+
timezone=cfg.refresh_timezone,
|
| 267 |
+
id="daily_refresh",
|
| 268 |
+
name="Daily news refresh",
|
| 269 |
+
replace_existing=True,
|
| 270 |
+
)
|
| 271 |
+
scheduler.start()
|
| 272 |
+
logger.info("Scheduler started — daily refresh at %02d:%02d %s", cfg.refresh_hour, cfg.refresh_minute, cfg.refresh_timezone)
|
| 273 |
+
except ImportError:
|
| 274 |
+
logger.warning("APScheduler not available — scheduled refresh disabled. Use PythonAnywhere tasks instead.")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ---- Entry points ----
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
_start_scheduler()
|
| 281 |
+
threading.Thread(target=refresh_data, daemon=True).start()
|
| 282 |
+
app.run(host=cfg.host, port=cfg.port, debug=(cfg.flask_env != "production"))
|