10
11
import argparse
12
import json
13
+import re
14
import sys
15
+import urllib.request
16
from datetime import datetime
17
from pathlib import Path
18
58
_HYPE_RISK_SEVERITY: dict[str, int] = {"high": 3, "medium": 2, "low": 1, "none": 0}
59
60
59
-def format_correlations_list(correlations: list[dict], *, top_n: int | None = None) -> str:
60
- """Format correlations into a markdown list.
61
+def _fetch_readme_snippet(full_name: str, max_chars: int = 500) -> str:
62
+ """Fetch the first max_chars of a repo README from raw.githubusercontent.com.
63
+
64
+ Returns an empty string on any failure (network error, 404, timeout).
65
+ Should only be called in reader_mode=True paths.
66
+ """
67
+ url = f"https://raw.githubusercontent.com/{full_name}/HEAD/README.md"
68
+ try:
69
+ req = urllib.request.Request(url, headers={"User-Agent": "SquadScope/1.0"})
70
+ with urllib.request.urlopen(req, timeout=5) as resp:
71
+ raw = resp.read(max_chars * 3)
72
+ return raw.decode("utf-8", errors="replace")[:max_chars]
73
+ except Exception:
74
+ return ""
75
+
76
+
77
+def _extract_readme_description(snippet: str) -> str:
78
+ """Return the first readable descriptive line from a README snippet.
79
+
80
+ Skips headings, badge lines, image tags, and blank lines.
81
+ Returns an empty string if nothing usable is found.
82
+ """
83
+ for line in snippet.splitlines():
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ if line.startswith(("#", "!", "<", "|", "[")):
88
+ continue
89
+ # Strip markdown formatting: links → text, remove bold/italic/code, strip HTML
90
+ line = re.sub(r"!\[.*?\]\(.*?\)", "", line)
91
+ line = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", line)
92
+ line = re.sub(r"<[^>]+>", "", line)
93
+ line = re.sub(r"[*_`>]", "", line)
94
+ line = line.strip(" .,;:")
95
+ if 20 <= len(line) <= 150:
96
+ return line
97
+ return ""
98
+
99
+
100
+def _format_correlations_narrative(
101
+ correlations: list[dict], articles: list[dict]
102
+) -> str:
103
+ """Generate narrative paragraphs explaining press-to-code correlations.
104
+
105
+ Groups correlations by GitHub org, fetches README snippets for top repos,
106
+ and produces 1–3 prose paragraphs with inline links to repos and articles.
107
+ Only called in reader_mode=True — README network fetches happen here.
108
+ """
109
+ if not correlations:
110
+ return "(No significant press correlations this week.)"
111
+
112
+ # URL → title lookup for inline article links
113
+ url_to_title: dict[str, str] = {
114
+ a["url"]: a["title"]
115
+ for a in articles
116
+ if a.get("url") and a.get("title")
117
+ }
118
+
119
+ # Sort correlations by confidence desc, hype_risk severity desc
120
+ sorted_corrs = sorted(
121
+ correlations,
122
+ key=lambda c: (
123
+ -c.get("correlation_confidence", 0.0),
124
+ -_HYPE_RISK_SEVERITY.get(c.get("hype_risk", "none"), 0),
125
+ ),
126
+ )
127
+
128
+ # Group by org (first segment of "owner/repo")
129
+ org_groups: dict[str, list[dict]] = {}
130
+ for corr in sorted_corrs:
131
+ repo = corr.get("repo", "")
132
+ if not repo:
133
+ continue
134
+ org = repo.split("/")[0]
135
+ org_groups.setdefault(org, []).append(corr)
136
+
137
+ def _group_score(corrs: list[dict]) -> float:
138
+ return sum(c.get("correlation_confidence", 0.0) for c in corrs)
139
+
140
+ top_groups = sorted(
141
+ org_groups.items(),
142
+ key=lambda kv: _group_score(kv[1]),
143
+ reverse=True,
144
+ )[:4]
145
+
146
+ # Fetch README snippets for the top repos across groups (max 6 total)
147
+ repos_to_fetch: list[str] = []
148
+ for _, group_corrs in top_groups:
149
+ for corr in group_corrs[:2]:
150
+ repo = corr.get("repo", "")
151
+ if repo and repo not in repos_to_fetch and len(repos_to_fetch) < 6:
152
+ repos_to_fetch.append(repo)
153
+
154
+ readme_snippets: dict[str, str] = {}
155
+ for repo in repos_to_fetch:
156
+ snippet = _fetch_readme_snippet(repo)
157
+ if snippet:
158
+ readme_snippets[repo] = snippet
159
+
160
+ total = len(correlations)
161
+ paragraphs: list[str] = []
162
+
163
+ for idx, (org, group_corrs) in enumerate(top_groups[:3]):
164
+ # Collect up to 2 article links for this group
165
+ article_links: list[str] = []
166
+ seen_article_urls: set[str] = set()
167
+ for corr in group_corrs:
168
+ for url in corr.get("matched_articles", []):
169
+ if url not in seen_article_urls and len(article_links) < 2:
170
+ seen_article_urls.add(url)
171
+ title = url_to_title.get(url, "")
172
+ if title:
173
+ article_links.append(f"[{title}]({url})")
174
+
175
+ # Collect up to 3 repo links with optional README description
176
+ repo_parts: list[str] = []
177
+ for corr in group_corrs[:3]:
178
+ repo = corr.get("repo", "")
179
+ if not repo:
180
+ continue
181
+ link = _repo_link(repo)
182
+ desc = _extract_readme_description(readme_snippets.get(repo, ""))
183
+ repo_parts.append(f"{link} — {desc}" if desc else link)
184
+
185
+ if not repo_parts:
186
+ continue
187
+
188
+ repos_str = _join_links(repo_parts)
189
+
190
+ if article_links:
191
+ arts_str = _join_links(article_links)
192
+ if idx == 0:
193
+ para = (
194
+ f"This week's TechCrunch coverage closely tracks developer activity "
195
+ f"across {total} repos. {org.capitalize()} featured prominently: "
196
+ f"coverage of {arts_str} aligns with activity in {repos_str}."
197
+ )
198
+ else:
199
+ para = (
200
+ f"{org.capitalize()}'s press footprint also intersects with GitHub: "
201
+ f"coverage of {arts_str} tracks activity in {repos_str}."
202
+ )
203
+ else:
204
+ if idx == 0:
205
+ para = (
206
+ f"This week's TechCrunch coverage closely tracks developer activity "
207
+ f"across {total} repos. {org.capitalize()} shows the strongest signal, "
208
+ f"with {repos_str} seeing notable GitHub traction."
209
+ )
210
+ else:
211
+ para = (
212
+ f"{org.capitalize()} also shows strong press-to-code correlation, "
213
+ f"with activity in {repos_str}."
214
+ )
215
+
216
+ paragraphs.append(para)
217
+
218
+ return (
219
+ "\n\n".join(paragraphs)
220
+ if paragraphs
221
+ else "(No significant press correlations this week.)"
222
+ )
223
+
224
+
225
+def format_correlations_list(
226
+ correlations: list[dict],
227
+ *,
228
+ top_n: int | None = None,
229
+ reader_mode: bool = False,
230
+ articles: list[dict] | None = None,
231
+) -> str:
232
+ """Format correlations into a markdown list or narrative prose.
233
234
Args:
235
correlations: List of correlation dicts.
64
- top_n: When set, show only the top N entries (sorted by confidence desc,
65
- then hype_risk severity desc) and append a "…and N more" summary line.
236
+ top_n: When set (and reader_mode=False), show only the top N entries
237
+ (sorted by confidence desc, then hype_risk severity desc) and
238
+ append a "…and N more" summary line.
239
+ reader_mode: When True, delegate to _format_correlations_narrative()
240
+ which produces prose paragraphs with inline links. top_n
241
+ is ignored in this mode.
242
+ articles: Article list used by the narrative formatter for URL→title
243
+ lookup. Ignored when reader_mode=False.
244
"""
245
if not correlations:
246
return "- (none)"
247
248
+ if reader_mode:
249
+ return _format_correlations_narrative(correlations, articles or [])
250
+
251
if top_n is not None:
252
sorted_corrs = sorted(
253
correlations,
532
rendered = rendered.replace("{articles_list}", format_articles_list(articles))
533
rendered = rendered.replace("{correlation_count}", str(correlation_count))
534
rendered = rendered.replace(
354
- "{correlations_list}", format_correlations_list(correlations, top_n=top_n)
535
+ "{correlations_list}",
536
+ format_correlations_list(
537
+ correlations,
538
+ top_n=top_n,
539
+ reader_mode=reader_mode,
540
+ articles=articles,
541
+ ),
542
)
543
544
# Strip the AI-only ### Instructions block in reader mode