skander101 commited on
Commit
a96145c
·
1 Parent(s): 3f4dc00

Deploy NewsApp

Browse files
Files changed (50) hide show
  1. Dockerfile +18 -0
  2. config.py +100 -0
  3. main.py +133 -0
  4. refresh_task.py +120 -0
  5. requirements.txt +22 -0
  6. src/__init__.py +0 -0
  7. src/__pycache__/__init__.cpython-312.pyc +0 -0
  8. src/__pycache__/aggregator.cpython-312.pyc +0 -0
  9. src/__pycache__/analyzer.cpython-312.pyc +0 -0
  10. src/__pycache__/extractor.cpython-312.pyc +0 -0
  11. src/__pycache__/hn_scraper.cpython-312.pyc +0 -0
  12. src/__pycache__/mockdata.cpython-312.pyc +0 -0
  13. src/__pycache__/models.cpython-312.pyc +0 -0
  14. src/__pycache__/presenter.cpython-312.pyc +0 -0
  15. src/__pycache__/rss_feed_scraper.cpython-312.pyc +0 -0
  16. src/__pycache__/rss_scraper.cpython-312.pyc +0 -0
  17. src/__pycache__/scraper.cpython-312.pyc +0 -0
  18. src/aggregator.py +156 -0
  19. src/analyzer.py +410 -0
  20. src/extractor.py +92 -0
  21. src/hn_scraper.py +48 -0
  22. src/mockdata.py +189 -0
  23. src/models.py +56 -0
  24. src/presenter.py +48 -0
  25. src/rss_feed_scraper.py +164 -0
  26. src/scraper.py +65 -0
  27. src/src/__init__.py +0 -0
  28. src/src/__pycache__/__init__.cpython-312.pyc +0 -0
  29. src/src/__pycache__/aggregator.cpython-312.pyc +0 -0
  30. src/src/__pycache__/analyzer.cpython-312.pyc +0 -0
  31. src/src/__pycache__/extractor.cpython-312.pyc +0 -0
  32. src/src/__pycache__/hn_scraper.cpython-312.pyc +0 -0
  33. src/src/__pycache__/mockdata.cpython-312.pyc +0 -0
  34. src/src/__pycache__/models.cpython-312.pyc +0 -0
  35. src/src/__pycache__/presenter.cpython-312.pyc +0 -0
  36. src/src/__pycache__/rss_feed_scraper.cpython-312.pyc +0 -0
  37. src/src/__pycache__/rss_scraper.cpython-312.pyc +0 -0
  38. src/src/__pycache__/scraper.cpython-312.pyc +0 -0
  39. src/src/aggregator.py +156 -0
  40. src/src/analyzer.py +410 -0
  41. src/src/extractor.py +92 -0
  42. src/src/hn_scraper.py +48 -0
  43. src/src/mockdata.py +189 -0
  44. src/src/models.py +56 -0
  45. src/src/presenter.py +48 -0
  46. src/src/rss_feed_scraper.py +164 -0
  47. src/src/scraper.py +65 -0
  48. templates/index.html +289 -0
  49. templates/templates/index.html +289 -0
  50. 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"))