skander101 commited on
Commit
84d1c3c
·
1 Parent(s): c97c10a

Rich sponsor ownership: shareholders, types, ownership chains

Browse files
Files changed (5) hide show
  1. refresh_task.py +4 -2
  2. src/analyzer.py +52 -50
  3. src/models.py +1 -1
  4. templates/sponsors.html +57 -25
  5. webapp.py +28 -12
refresh_task.py CHANGED
@@ -55,7 +55,8 @@ def serialize_clusters(clusters):
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
- "sponsor": a.analysis.sponsor if a.analysis else "",
 
59
  "score": a.post.score,
60
  "comments": a.post.num_comments,
61
  "url": a.post.url,
@@ -85,7 +86,8 @@ def serialize_clusters(clusters):
85
  "summary": a.analysis.summary if a.analysis else "",
86
  "topics": a.analysis.topics if a.analysis else [],
87
  "trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
88
- "sponsor": a.analysis.sponsor if a.analysis else "",
 
89
  "score": a.post.score,
90
  "comments": a.post.num_comments,
91
  "image": a.article.image_url if a.article else a.post.image_url,
 
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
+ "sponsor": a.analysis.sponsor.get("display", "") if a.analysis else "",
59
+ "sponsor_info": a.analysis.sponsor if a.analysis else {},
60
  "score": a.post.score,
61
  "comments": a.post.num_comments,
62
  "url": a.post.url,
 
86
  "summary": a.analysis.summary if a.analysis else "",
87
  "topics": a.analysis.topics if a.analysis else [],
88
  "trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
89
+ "sponsor": a.analysis.sponsor.get("display", "") if a.analysis else "",
90
+ "sponsor_info": a.analysis.sponsor if a.analysis else {},
91
  "score": a.post.score,
92
  "comments": a.post.num_comments,
93
  "image": a.article.image_url if a.article else a.post.image_url,
src/analyzer.py CHANGED
@@ -20,54 +20,53 @@ UNRELIABLE_DOMAINS = {
20
  "theonion.com": -0.25, "naturalnews.com": -0.30, "zerohedge.com": -0.15,
21
  }
22
 
23
- SPONSORS: dict[str, str] = {
24
- "bbc.com": "BBC (publicly funded)",
25
- "bbc.co.uk": "BBC (publicly funded)",
26
- "nytimes.com": "The New York Times Company",
27
- "npr.org": "NPR (publicly funded)",
28
- "aljazeera.com": "Al Jazeera Media Network (Qatar-funded)",
29
- "theguardian.com": "Guardian Media Group",
30
- "statnews.com": "Boston Globe Media",
31
- "sciencedaily.com": "ScienceDaily LLC",
32
- "techcrunch.com": "Yahoo (Apollo Global Management)",
33
- "wired.com": "Condé Nast (Advance Publications)",
34
- "theverge.com": "Vox Media",
35
- "arstechnica.com": "Condé Nast (Advance Publications)",
36
- "krebsonsecurity.com": "Independent (Brian Krebs)",
37
- "bleepingcomputer.com": "Independent",
38
- "threatpost.com": "Independent",
39
- "therecord.media": "Recorded Future",
40
- "theonion.com": "Global Tetrahedron",
41
- "thedailymash.co.uk": "Independent",
42
- "babylonbee.com": "Independent",
43
- "ign.com": "Ziff Davis",
44
- "eurogamer.net": "ReedPop (Gamer Network)",
45
- "pcgamer.com": "Future plc",
46
- "kotaku.com": "G/O Media",
47
- "gamespot.com": "Fandom",
48
- "polygon.com": "Vox Media",
49
- "variety.com": "Penske Media Corporation",
50
- "hollywoodreporter.com": "Penske Media Corporation",
51
- "deadline.com": "Penske Media Corporation",
52
- "screenrant.com": "Valnet Inc.",
53
- "arabnews.com": "Saudi Research & Publishing",
54
- "middleeasteye.net": "Independent (London-based)",
55
- "newarab.com": "Fikra Publishing",
56
- "france24.com": "France Médias Monde (government-funded)",
57
- "tunisiaonlinenews.com": "Independent",
58
- "northafricapost.com": "Independent",
59
- "africanews.com": "Independent",
60
- "reuters.com": "Thomson Reuters",
61
- "apnews.com": "Associated Press (non-profit)",
62
- "wsj.com": "Dow Jones (News Corp)",
63
- "economist.com": "The Economist Group",
64
- "nature.com": "Springer Nature",
65
- "science.org": "American Association for the Advancement of Science",
66
- "washingtonpost.com": "The Washington Post (Jeff Bezos)",
67
- "ft.com": "Financial Times (Nikkei)",
68
- "bloomberg.com": "Bloomberg L.P. (Michael Bloomberg)",
69
- "thehackernews.com": "Independent (THN)",
70
- "ign.com": "Ziff Davis",
71
  }
72
 
73
  CLICKBAIT_PATTERNS = [
@@ -250,9 +249,12 @@ class NewsAnalyzer:
250
  logger.warning("Model loading failed: %s — using rule-based", exc)
251
 
252
  @staticmethod
253
- def _detect_sponsor(article: Article) -> str:
254
  domain = re.sub(r"^www\.", "", (article.source_domain or ""))
255
- return SPONSORS.get(domain, "")
 
 
 
256
 
257
  def analyze(self, article: Article) -> Analysis:
258
  summary = self._summarise(article)
 
20
  "theonion.com": -0.25, "naturalnews.com": -0.30, "zerohedge.com": -0.15,
21
  }
22
 
23
+ SPONSOR_INFO: dict[str, dict] = {
24
+ "bbc.com": {"display": "BBC (publicly funded)", "parent": "BBC", "type": "state", "owners": ["UK Government (licence fee payers)"]},
25
+ "bbc.co.uk": {"display": "BBC (publicly funded)", "parent": "BBC", "type": "state", "owners": ["UK Government (licence fee payers)"]},
26
+ "nytimes.com": {"display": "The New York Times Company", "parent": "The New York Times Company", "type": "public", "owners": ["Ochs-Sulzberger family (controlling)", "Public shareholders (NYSE: NYT)"]},
27
+ "npr.org": {"display": "NPR (publicly funded)", "parent": "National Public Radio", "type": "nonprofit", "owners": ["Corporation for Public Broadcasting", "Member stations", "Listeners/donors"]},
28
+ "aljazeera.com": {"display": "Al Jazeera Media Network", "parent": "Al Jazeera Media Network", "type": "state", "owners": ["Government of Qatar"]},
29
+ "theguardian.com": {"display": "Guardian Media Group", "parent": "Guardian Media Group", "type": "nonprofit", "owners": ["Scott Trust Limited (no shareholders)"]},
30
+ "statnews.com": {"display": "Boston Globe Media", "parent": "Boston Globe Media Partners", "type": "private", "owners": ["John W. Henry (also owns Boston Red Sox)"]},
31
+ "sciencedaily.com": {"display": "ScienceDaily LLC", "parent": "ScienceDaily LLC", "type": "private", "owners": ["Dan Hogan (founder)"]},
32
+ "techcrunch.com": {"display": "TechCrunch (Yahoo)", "parent": "Yahoo Inc.", "type": "private", "owners": ["Apollo Global Management"]},
33
+ "wired.com": {"display": "Wired (Condé Nast)", "parent": "Condé Nast", "type": "private", "owners": ["Advance Publications", "Newhouse family"]},
34
+ "theverge.com": {"display": "The Verge (Vox Media)", "parent": "Vox Media", "type": "private", "owners": ["NBCUniversal (Comcast)", "Accel Partners", "General Atlantic"]},
35
+ "arstechnica.com": {"display": "Ars Technica (Condé Nast)", "parent": "Condé Nast", "type": "private", "owners": ["Advance Publications", "Newhouse family"]},
36
+ "krebsonsecurity.com": {"display": "Krebs on Security", "parent": "Independent", "type": "independent", "owners": ["Brian Krebs (independent journalist)"]},
37
+ "bleepingcomputer.com": {"display": "BleepingComputer", "parent": "Independent", "type": "independent", "owners": ["Lawrence Abrams (founder)"]},
38
+ "threatpost.com": {"display": "Threatpost", "parent": "Independent", "type": "independent", "owners": ["Independent editorial team"]},
39
+ "therecord.media": {"display": "The Record (Recorded Future)", "parent": "Recorded Future", "type": "private", "owners": ["Insikt Group", "Mastercard (parent company)"]},
40
+ "theonion.com": {"display": "The Onion", "parent": "Global Tetrahedron", "type": "private", "owners": ["Global Tetrahedron LLC"]},
41
+ "thedailymash.co.uk": {"display": "Daily Mash", "parent": "Independent", "type": "independent", "owners": ["Neil Rafferty (founder)"]},
42
+ "babylonbee.com": {"display": "Babylon Bee", "parent": "Independent", "type": "independent", "owners": ["Seth Dillon (CEO)"]},
43
+ "ign.com": {"display": "IGN (Ziff Davis)", "parent": "Ziff Davis", "type": "public", "owners": ["Public shareholders (NASDAQ: ZD)"]},
44
+ "eurogamer.net": {"display": "Eurogamer (ReedPop)", "parent": "ReedPop (Gamer Network)", "type": "public", "owners": ["RELX Group (parent of Reed Elsevier)", "Public shareholders"]},
45
+ "pcgamer.com": {"display": "PC Gamer (Future plc)", "parent": "Future plc", "type": "public", "owners": ["Public shareholders (LSE: FUTR)"]},
46
+ "kotaku.com": {"display": "Kotaku (G/O Media)", "parent": "G/O Media", "type": "private", "owners": ["Great Hill Partners (private equity)"]},
47
+ "gamespot.com": {"display": "GameSpot (Fandom)", "parent": "Fandom Inc.", "type": "private", "owners": ["TPG Capital", "Integrated Media Co."]},
48
+ "polygon.com": {"display": "Polygon (Vox Media)", "parent": "Vox Media", "type": "private", "owners": ["NBCUniversal (Comcast)", "Accel Partners", "General Atlantic"]},
49
+ "variety.com": {"display": "Variety (Penske Media)", "parent": "Penske Media Corporation", "type": "private", "owners": ["Jay Penske (founder/CEO)"]},
50
+ "hollywoodreporter.com": {"display": "Hollywood Reporter (Penske Media)", "parent": "Penske Media Corporation", "type": "private", "owners": ["Jay Penske (founder/CEO)"]},
51
+ "deadline.com": {"display": "Deadline (Penske Media)", "parent": "Penske Media Corporation", "type": "private", "owners": ["Jay Penske (founder/CEO)"]},
52
+ "screenrant.com": {"display": "Screen Rant (Valnet)", "parent": "Valnet Inc.", "type": "private", "owners": ["Valnet Inc. (digital media group)"]},
53
+ "arabnews.com": {"display": "Arab News", "parent": "Saudi Research & Publishing", "type": "state", "owners": ["Saudi Government (via SRPC)"]},
54
+ "middleeasteye.net": {"display": "Middle East Eye", "parent": "Independent", "type": "independent", "owners": ["Independent (London-based, reader-supported)"]},
55
+ "newarab.com": {"display": "The New Arab", "parent": "Fikra Publishing", "type": "private", "owners": ["Fikra Publishing Ltd."]},
56
+ "france24.com": {"display": "France 24", "parent": "France Médias Monde", "type": "state", "owners": ["French Government (via public funding)"]},
57
+ "tunisiaonlinenews.com": {"display": "Tunisia Online News", "parent": "Independent", "type": "independent", "owners": ["Independent editorial team"]},
58
+ "northafricapost.com": {"display": "North Africa Post", "parent": "Independent", "type": "independent", "owners": ["Independent editorial team"]},
59
+ "africanews.com": {"display": "Africa News", "parent": "Independent", "type": "independent", "owners": ["Independent (NGO-funded)"]},
60
+ "reuters.com": {"display": "Reuters (Thomson Reuters)", "parent": "Thomson Reuters", "type": "public", "owners": ["The Woodbridge Company (Thomson family, controlling)", "Public shareholders"]},
61
+ "apnews.com": {"display": "Associated Press", "parent": "Associated Press", "type": "nonprofit", "owners": ["Member newspapers (cooperative, no shareholders)"]},
62
+ "wsj.com": {"display": "Wall Street Journal (News Corp)", "parent": "Dow Jones (News Corp)", "type": "public", "owners": ["Murdoch family (controlling, via News Corp)", "Public shareholders (NASDAQ: NWSA)"]},
63
+ "economist.com": {"display": "The Economist", "parent": "The Economist Group", "type": "private", "owners": ["Pearson plc (50%)", "The Rothschild family", "The Economist editorial staff"]},
64
+ "nature.com": {"display": "Nature (Springer Nature)", "parent": "Springer Nature", "type": "private", "owners": ["Holtzbrinck Publishing Group", "BC Partners"]},
65
+ "science.org": {"display": "Science (AAAS)", "parent": "American Association for the Advancement of Science", "type": "nonprofit", "owners": ["AAAS membership (non-profit society)"]},
66
+ "washingtonpost.com": {"display": "The Washington Post", "parent": "The Washington Post", "type": "private", "owners": ["Jeff Bezos (Nash Holdings)"]},
67
+ "ft.com": {"display": "Financial Times (Nikkei)", "parent": "Financial Times", "type": "public", "owners": ["Nikkei Inc. (Japanese media group)"]},
68
+ "bloomberg.com": {"display": "Bloomberg", "parent": "Bloomberg L.P.", "type": "private", "owners": ["Michael Bloomberg (88%)", "Merck family (12%)"]},
69
+ "thehackernews.com": {"display": "The Hacker News", "parent": "Independent", "type": "independent", "owners": ["Independent editorial team"]},
 
70
  }
71
 
72
  CLICKBAIT_PATTERNS = [
 
249
  logger.warning("Model loading failed: %s — using rule-based", exc)
250
 
251
  @staticmethod
252
+ def _detect_sponsor(article: Article) -> dict:
253
  domain = re.sub(r"^www\.", "", (article.source_domain or ""))
254
+ info = SPONSOR_INFO.get(domain)
255
+ if info:
256
+ return dict(info)
257
+ return {"display": "", "parent": "", "type": "", "owners": []}
258
 
259
  def analyze(self, article: Article) -> Analysis:
260
  summary = self._summarise(article)
src/models.py CHANGED
@@ -34,7 +34,7 @@ class Analysis:
34
  is_opinion: bool
35
  political_leaning: str = "centrist"
36
  category: str = "General"
37
- sponsor: str = ""
38
 
39
 
40
  @dataclass
 
34
  is_opinion: bool
35
  political_leaning: str = "centrist"
36
  category: str = "General"
37
+ sponsor: dict = field(default_factory=dict)
38
 
39
 
40
  @dataclass
templates/sponsors.html CHANGED
@@ -23,7 +23,7 @@ body {
23
  backdrop-filter: blur(12px);
24
  }
25
  .header-inner {
26
- max-width: 1400px;
27
  margin: 0 auto;
28
  display: flex;
29
  align-items: center;
@@ -38,6 +38,7 @@ body {
38
  -webkit-background-clip: text;
39
  -webkit-text-fill-color: transparent;
40
  }
 
41
  .back-btn {
42
  padding: 8px 18px;
43
  border-radius: 8px;
@@ -49,26 +50,46 @@ body {
49
  transition: background .15s;
50
  }
51
  .back-btn:hover { background: #475569; color: #e2e8f0; }
52
- .container { max-width: 1000px; margin: 0 auto; padding: 32px; }
53
  .grid { display: grid; gap: 16px; }
54
  .card {
55
  background: #1e293b;
56
  border-radius: 14px;
57
  padding: 20px 24px;
58
  border: 1px solid #334155;
 
59
  }
60
- .card h2 {
61
- font-size: 16px;
 
 
 
62
  font-weight: 600;
63
- color: #fbbf24;
64
- margin-bottom: 4px;
 
 
65
  }
66
- .card .sub {
 
 
 
 
 
 
 
 
 
67
  font-size: 12px;
68
- color: #64748b;
69
- margin-bottom: 12px;
 
 
 
70
  }
71
- .sources { display: flex; flex-wrap: wrap; gap: 6px; }
 
 
72
  .source-tag {
73
  font-size: 12px;
74
  padding: 4px 10px;
@@ -79,14 +100,6 @@ body {
79
  }
80
  .empty { text-align: center; padding: 60px 20px; color: #64748b; }
81
  .empty h2 { font-size: 18px; margin-bottom: 6px; }
82
- .count-badge {
83
- font-size: 11px;
84
- color: #818cf8;
85
- background: rgba(129, 140, 248, 0.12);
86
- padding: 2px 8px;
87
- border-radius: 5px;
88
- margin-left: 8px;
89
- }
90
  @media (max-width: 640px) {
91
  .header { padding: 14px; }
92
  .container { padding: 14px; }
@@ -96,7 +109,10 @@ body {
96
  <body>
97
  <div class="header">
98
  <div class="header-inner">
99
- <h1>News Digest — Sponsors</h1>
 
 
 
100
  <a href="/" class="back-btn">← Back to News</a>
101
  </div>
102
  </div>
@@ -105,12 +121,28 @@ body {
105
  <div class="grid">
106
  {% for s in sponsors %}
107
  <div class="card">
108
- <h2>🏢 {{ s.name }}</h2>
109
- <div class="sub">{{ s.source_count }} source{% if s.source_count != 1 %}s{% endif %}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  <div class="sources">
111
- {% for domain in s.sources %}
112
- <span class="source-tag">{{ domain }}</span>
113
- {% endfor %}
 
 
 
114
  </div>
115
  </div>
116
  {% endfor %}
@@ -118,7 +150,7 @@ body {
118
  {% else %}
119
  <div class="empty">
120
  <h2>No sponsor data yet</h2>
121
- <p>Run a data refresh to populate sponsor information</p>
122
  </div>
123
  {% endif %}
124
  </div>
 
23
  backdrop-filter: blur(12px);
24
  }
25
  .header-inner {
26
+ max-width: 1200px;
27
  margin: 0 auto;
28
  display: flex;
29
  align-items: center;
 
38
  -webkit-background-clip: text;
39
  -webkit-text-fill-color: transparent;
40
  }
41
+ .subtitle { font-size: 13px; color: #64748b; }
42
  .back-btn {
43
  padding: 8px 18px;
44
  border-radius: 8px;
 
50
  transition: background .15s;
51
  }
52
  .back-btn:hover { background: #475569; color: #e2e8f0; }
53
+ .container { max-width: 900px; margin: 0 auto; padding: 32px; }
54
  .grid { display: grid; gap: 16px; }
55
  .card {
56
  background: #1e293b;
57
  border-radius: 14px;
58
  padding: 20px 24px;
59
  border: 1px solid #334155;
60
+ transition: border-color .15s;
61
  }
62
+ .card:hover { border-color: #fbbf24; }
63
+ .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; flex-wrap: wrap; gap: 8px; }
64
+ .card h2 { font-size: 16px; font-weight: 600; color: #fbbf24; }
65
+ .type-badge {
66
+ font-size: 10px;
67
  font-weight: 600;
68
+ text-transform: uppercase;
69
+ letter-spacing: .08em;
70
+ padding: 3px 8px;
71
+ border-radius: 5px;
72
  }
73
+ .type-public { background: rgba(59,130,246,0.15); color: #60a5fa; }
74
+ .type-private { background: rgba(251,191,36,0.15); color: #fbbf24; }
75
+ .type-state { background: rgba(239,68,68,0.15); color: #f87171; }
76
+ .type-nonprofit { background: rgba(34,197,94,0.15); color: #4ade80; }
77
+ .type-independent { background: rgba(148,163,184,0.15); color: #94a3b8; }
78
+ .card .parent { font-size: 13px; color: #94a3b8; margin-bottom: 12px; }
79
+ .owners { margin-bottom: 12px; }
80
+ .owners h3 { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
81
+ .owner-list { display: flex; flex-wrap: wrap; gap: 6px; }
82
+ .owner-tag {
83
  font-size: 12px;
84
+ padding: 4px 10px;
85
+ border-radius: 6px;
86
+ background: rgba(251,191,36,0.08);
87
+ color: #fbbf24;
88
+ border: 1px solid rgba(251,191,36,0.2);
89
  }
90
+ .sources { margin-top: 12px; padding-top: 12px; border-top: 1px solid #334155; }
91
+ .sources h3 { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
92
+ .source-list { display: flex; flex-wrap: wrap; gap: 6px; }
93
  .source-tag {
94
  font-size: 12px;
95
  padding: 4px 10px;
 
100
  }
101
  .empty { text-align: center; padding: 60px 20px; color: #64748b; }
102
  .empty h2 { font-size: 18px; margin-bottom: 6px; }
 
 
 
 
 
 
 
 
103
  @media (max-width: 640px) {
104
  .header { padding: 14px; }
105
  .container { padding: 14px; }
 
109
  <body>
110
  <div class="header">
111
  <div class="header-inner">
112
+ <div>
113
+ <h1>🏢 Who Owns the News?</h1>
114
+ <div class="subtitle">Track the parent companies and shareholders behind each news source</div>
115
+ </div>
116
  <a href="/" class="back-btn">← Back to News</a>
117
  </div>
118
  </div>
 
121
  <div class="grid">
122
  {% for s in sponsors %}
123
  <div class="card">
124
+ <div class="card-header">
125
+ <h2>{{ s.display }}</h2>
126
+ <span class="type-badge type-{{ s.type }}">{{ s.type }}</span>
127
+ </div>
128
+ <div class="parent">Parent: {{ s.parent }}</div>
129
+ {% if s.owners %}
130
+ <div class="owners">
131
+ <h3>Shareholders / Funders</h3>
132
+ <div class="owner-list">
133
+ {% for owner in s.owners %}
134
+ <span class="owner-tag">{{ owner }}</span>
135
+ {% endfor %}
136
+ </div>
137
+ </div>
138
+ {% endif %}
139
  <div class="sources">
140
+ <h3>{{ s.source_count }} outlet{% if s.source_count != 1 %}s{% endif %} owned</h3>
141
+ <div class="source-list">
142
+ {% for domain in s.sources %}
143
+ <span class="source-tag">{{ domain }}</span>
144
+ {% endfor %}
145
+ </div>
146
  </div>
147
  </div>
148
  {% endfor %}
 
150
  {% else %}
151
  <div class="empty">
152
  <h2>No sponsor data yet</h2>
153
+ <p>Run a data refresh to populate ownership information</p>
154
  </div>
155
  {% endif %}
156
  </div>
webapp.py CHANGED
@@ -55,7 +55,8 @@ def _cluster_to_html_dict(c):
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
- "sponsor": a.analysis.sponsor if a.analysis else "",
 
59
  "score": a.post.score,
60
  "comments": a.post.num_comments,
61
  "url": a.post.url,
@@ -90,7 +91,8 @@ def _cluster_to_api_dict(c):
90
  "summary": a.analysis.summary if a.analysis else "",
91
  "topics": a.analysis.topics if a.analysis else [],
92
  "trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
93
- "sponsor": a.analysis.sponsor if a.analysis else "",
 
94
  "score": a.post.score,
95
  "comments": a.post.num_comments,
96
  "image": a.article.image_url if a.article else a.post.image_url,
@@ -227,21 +229,35 @@ def api_categories():
227
  def _build_sponsors():
228
  with cache_lock:
229
  results = list(cached_results)
230
- groups: dict[str, set[str]] = {}
231
  for cluster in results:
232
  for article in cluster.get("articles", []):
233
- sponsor = article.get("sponsor", "")
234
- domain = article.get("domain", "")
235
- if sponsor:
236
- if sponsor not in groups:
237
- groups[sponsor] = set()
238
- groups[sponsor].add(domain)
 
 
 
 
 
 
 
239
  return sorted(
240
  [
241
- {"name": s, "sources": sorted(doms), "source_count": len(doms)}
242
- for s, doms in groups.items()
 
 
 
 
 
 
 
243
  ],
244
- key=lambda x: x["name"].lower(),
245
  )
246
 
247
 
 
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
+ "sponsor": a.analysis.sponsor.get("display", "") if a.analysis else "",
59
+ "sponsor_info": a.analysis.sponsor if a.analysis else {},
60
  "score": a.post.score,
61
  "comments": a.post.num_comments,
62
  "url": a.post.url,
 
91
  "summary": a.analysis.summary if a.analysis else "",
92
  "topics": a.analysis.topics if a.analysis else [],
93
  "trust": round(a.analysis.trustworthiness_score, 2) if a.analysis else 0,
94
+ "sponsor": a.analysis.sponsor.get("display", "") if a.analysis else "",
95
+ "sponsor_info": a.analysis.sponsor if a.analysis else {},
96
  "score": a.post.score,
97
  "comments": a.post.num_comments,
98
  "image": a.article.image_url if a.article else a.post.image_url,
 
229
  def _build_sponsors():
230
  with cache_lock:
231
  results = list(cached_results)
232
+ groups: dict[str, dict] = {}
233
  for cluster in results:
234
  for article in cluster.get("articles", []):
235
+ info = article.get("sponsor_info", {})
236
+ display = info.get("display", "") or article.get("sponsor", "")
237
+ if not display:
238
+ continue
239
+ if display not in groups:
240
+ groups[display] = {
241
+ "display": display,
242
+ "parent": info.get("parent", ""),
243
+ "type": info.get("type", ""),
244
+ "owners": info.get("owners", []),
245
+ "sources": set(),
246
+ }
247
+ groups[display]["sources"].add(article.get("domain", ""))
248
  return sorted(
249
  [
250
+ {
251
+ "display": g["display"],
252
+ "parent": g["parent"],
253
+ "type": g["type"],
254
+ "owners": g["owners"],
255
+ "sources": sorted(g["sources"]),
256
+ "source_count": len(g["sources"]),
257
+ }
258
+ for g in groups.values()
259
  ],
260
+ key=lambda x: x["display"].lower(),
261
  )
262
 
263