feat: harden external news telemetry (#242)

Closes #237. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 5, 2026 at 19:24 UTC 498496e3fd00689a460b8a73542eef5c3f8a0f3a
10 files changed +795 -41
.github/workflows/crawl-and-publish.yml
+36 -1
@@ -109,10 +109,29 @@ jobs:
109 run: |
110 WEEK=$(date +%Y-W%V)
111 SINCE=$(date -d '7 days ago' +%Y-%m-%d)
112 + UNTIL=$(date +%Y-%m-%d)
113 python scripts/techcrunch_crawler.py \
114 --sources config/external_news_sources.json \
115 --output "data/raw/${WEEK}-external-news.json" \
115 - --since "$SINCE"
116 + --since "$SINCE" \
117 + --until "$UNTIL"
118 + python3 - <<'PY' "data/raw/${WEEK}-external-news.json"
119 + import json
120 + import sys
121 + from pathlib import Path
122 +
123 + path = Path(sys.argv[1])
124 + payload = json.loads(path.read_text(encoding="utf-8"))
125 + metadata = payload.get("metadata", {})
126 + print(
127 + "::notice::External news artifact "
128 + f"size_bytes={path.stat().st_size} "
129 + f"articles={metadata.get('total_articles', 0)} "
130 + f"relevant={metadata.get('relevant_articles', 0)} "
131 + f"dedupe={metadata.get('dedupe_count', 0)} "
132 + f"checksum={metadata.get('artifact_checksum', '')[:12]}"
133 + )
134 + PY
135
136 - name: Upload raw crawl artifact
137 if: always()
@@ -296,6 +315,19 @@ jobs:
315 PRESS_CONTEXT=$(python scripts/render_press_context.py --week "$WEEK")
316 PRESS_FILE="data/analyzed/${WEEK}-press-context.md"
317 printf '%s\n' "$PRESS_CONTEXT" > "$PRESS_FILE"
318 + python3 - <<'PY' "$PRESS_FILE"
319 + import sys
320 + from pathlib import Path
321 + from scripts.render_press_context import estimate_tokens
322 +
323 + path = Path(sys.argv[1])
324 + content = path.read_text(encoding="utf-8")
325 + print(
326 + "::notice::Press context "
327 + f"size_bytes={path.stat().st_size} "
328 + f"token_estimate={estimate_tokens(content)}"
329 + )
330 + PY
331 echo "press_file=$PRESS_FILE" >> "$GITHUB_OUTPUT"
332
333 - name: Pre-flight cost check
@@ -330,6 +362,7 @@ jobs:
362 WEEK="${{ steps.analysis-context.outputs.week }}"
363 CURRENT_DATETIME="${{ steps.analysis-context.outputs.current_datetime }}"
364 PRESS_FILE="${{ steps.press-context.outputs.press_file }}"
365 + ANALYSIS_STARTED=$(date +%s)
366 mkdir -p data/metrics
367 # Hydrate metrics ledger from publish so track_token_usage.py appends to
368 # the canonical token-usage.jsonl rather than starting fresh each run.
@@ -468,6 +501,8 @@ jobs:
501 --prompt-file "$PROMPT_FILE" \
502 --output-file "$OUTPUT_FILE" \
503 $TRANSCRIPT_ARGS
504 + ANALYSIS_DURATION=$(( $(date +%s) - ANALYSIS_STARTED ))
505 + echo "::notice::Analysis path source=$ANALYSIS_SOURCE model=$ANALYSIS_MODEL duration_seconds=$ANALYSIS_DURATION press_context=$PRESS_FILE"
506 rm -f "$PROMPT_FILE"
507 # Clean up per-attempt transcripts
508 rm -f data/metrics/copilot-transcript-attempt-*.md
.squad/agents/bender/history.md
+5
@@ -36,3 +36,8 @@
36 - Decision recorded in .squad/decisions.md; GitHub issue #237 created for implementation.
37 - External RSS crawlers must validate config URLs against an HTTPS host allowlist and fetch through explicit per-request timeouts; config-driven source lists are not a security boundary by themselves (PR #236).
38 - Local validation docs for scripts importing `scripts.*` modules should use `python3 -m ...` or set `PYTHONPATH=.` so repo-root imports resolve reliably (PR #236).
39 +
40 +## Issue #237 canonical external-news telemetry (2026-06-05)
41 +
42 +- Multi-source RSS remains in-process, but downstream reliability depends on a versioned canonical artifact: include crawl window, source config checksum, per-source statuses, partial failures, dedupe count, and deterministic checksum in `*-external-news.json`.
43 +- Press correlation must retain bounded source-aware citations and label category/fuzzy-only matches as weak so mirrored coverage or broad topics do not inflate strong press claims.
.squad/decisions/inbox/bender-issue-237-implementation.md new
+15
@@ -0,0 +1,15 @@
1 +# Bender issue #237 implementation
2 +
3 +Date: 2026-06-05
4 +
5 +## Decision
6 +
7 +Keep external RSS/news in the existing crawl job with bounded in-process parallelism, but promote the handoff to a canonical `schema_version: 2` `data/raw/{week}-external-news.json` artifact. The artifact carries crawl window, source config checksum, requested/succeeded/failed sources, per-source status metrics, dedupe count, deterministic checksum, and partial-failure metadata.
8 +
9 +## Rationale
10 +
11 +The measured bottleneck remains the GitHub repository crawl, not the five-source RSS step. Source-aware telemetry and schema validation improve downstream reliability without adding Actions matrix startup overhead or splitting cache/API behavior.
12 +
13 +## Operational notes
14 +
15 +`correlate.py` and `render_press_context.py` now preserve article source/title/date/URL citations, label strong versus weak correlations, bound press context size to an ~8k token estimate, and keep legacy `*-techcrunch.json` and no-press fallbacks.
docs/pipeline-validation.md
+8
@@ -38,6 +38,8 @@ Required secrets/tokens:
38 **Success criteria**
39 - Raw payload passes `scripts.crawl.validate_payload()`
40 - External RSS payload is written for the same ISO week from `config/external_news_sources.json`
41 +- `data/raw/YYYY-WNN-external-news.json` uses canonical `schema_version: 2`, includes `crawl_window`, `source_config_checksum`, `sources_requested/succeeded/failed`, per-source status rows, `dedupe_count`, and `artifact_checksum`
42 +- Optional per-source RSS failures are warnings with a valid partial artifact; malformed config/schema/checksum errors fail the crawl step
43 - Snapshot file is written for the same ISO week
44 - Cache artifact uploads even on partial failures
45 - Job permissions include `actions: read` and `contents: write` at workflow level for cache restore and commits
@@ -53,12 +55,16 @@ Required secrets/tokens:
55
56 **Outputs**
57 - `data/analyzed/YYYY-WNN-summary.md`
58 +- `data/analyzed/YYYY-WNN-correlations.json`
59 +- `data/analyzed/YYYY-WNN-press-context.md`
60 - `analyzed-data` artifact
61 - Commit to `main` for `data/analyzed/`
62 - Job outputs: `week`, `summary_file`, `current_datetime`
63
64 **Success criteria**
65 - Current raw file week matches the run week
66 +- Correlation and press-context steps consume compact external-news data with legacy `YYYY-WNN-techcrunch.json` fallback
67 +- Press context preserves source names, article URLs/titles/dates, strong-vs-weak labels, and partial-source caveats while staying under the ~8k token budget
68 - Copilot CLI output or fallback output is written to `data/analyzed/`
69 - `scripts/analysis_gate.py` passes before publish continues
70 - Job permissions include `actions: read`, `contents: write`, `copilot-requests: write`, and `models: read`
@@ -121,6 +127,8 @@ Required secrets/tokens:
127
128 - Crawl: `python3 scripts/crawl.py --as-of YYYY-MM-DD`
129 - External news crawl: `python3 -m scripts.techcrunch_crawler --sources config/external_news_sources.json --output data/raw/YYYY-WNN-external-news.json --since YYYY-MM-DD --until YYYY-MM-DD`
130 +- Correlate press: `python3 -m scripts.correlate --raw data/raw/YYYY-WNN.json --techcrunch data/raw/YYYY-WNN-external-news.json --output data/analyzed/YYYY-WNN-correlations.json`
131 +- Render press context: `python3 -m scripts.render_press_context --week YYYY-WNN`
132 - Analyze fallback: `python3 scripts/analyze_fallback.py --raw-json data/raw/YYYY-WNN.json --output data/analyzed/YYYY-WNN-summary.md --current-datetime YYYY-MM-DDTHH:MM:SSZ`
133 - Gate: `python3 scripts/analysis_gate.py --analysis-file data/analyzed/YYYY-WNN-summary.md --raw-json data/raw/YYYY-WNN.json --current-datetime YYYY-MM-DDTHH:MM:SSZ`
134 - Generate: `python3 scripts/generate_content.py data/analyzed/YYYY-WNN-summary.md`
scripts/correlate.py
+199 -9
@@ -23,6 +23,12 @@ from typing import Any
23
24 from scripts.topic_paths import analyzed_dir, raw_dir
25
26 +MAX_ARTICLES_FOR_CORRELATION = 80
27 +MAX_CORRELATIONS = 50
28 +MAX_MATCHED_ARTICLES_PER_REPO = 5
29 +MAX_DIVERGENCE_ARTICLES = 30
30 +WEAK_MATCH_TYPES = {"category", "project_name"}
31 +
32
33 def log(message: str) -> None:
34 print(f"[correlate] {message}", file=sys.stderr)
@@ -125,6 +131,114 @@ def match_category(repo: dict[str, Any], articles: list[dict[str, Any]]) -> list
131 return matches
132
133
134 +def _normalized_article_url(url: str) -> str:
135 + """Normalize an article URL for dedupe and citation joins."""
136 + if not url:
137 + return ""
138 + from urllib.parse import urlparse
139 +
140 + parsed = urlparse(url.strip())
141 + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{parsed.path.rstrip('/')}"
142 +
143 +
144 +def dedupe_articles(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
145 + """Deduplicate cross-source/mirrored stories by normalized URL."""
146 + grouped: dict[str, dict[str, Any]] = {}
147 + duplicates = 0
148 + for article in sorted(
149 + articles,
150 + key=lambda item: (
151 + item.get("published_at", ""),
152 + item.get("source", ""),
153 + item.get("url", ""),
154 + item.get("title", ""),
155 + ),
156 + reverse=True,
157 + ):
158 + key = _normalized_article_url(str(article.get("url", "")))
159 + if not key:
160 + key = str(article.get("title", "")).strip().lower()
161 + if key not in grouped:
162 + current = dict(article)
163 + current["sources"] = sorted({
164 + str(current.get("source", "")) or "unknown",
165 + *[str(source) for source in current.get("sources", [])],
166 + })
167 + grouped[key] = current
168 + continue
169 + duplicates += 1
170 + existing = grouped[key]
171 + sources = set(existing.get("sources", []))
172 + sources.add(str(article.get("source", "")) or "unknown")
173 + sources.update(str(source) for source in article.get("sources", []))
174 + existing["sources"] = sorted(sources)
175 + existing["relevance_score"] = max(
176 + float(existing.get("relevance_score", 0)),
177 + float(article.get("relevance_score", 0)),
178 + )
179 + existing_links = list(existing.get("github_links", []))
180 + for link in article.get("github_links", []):
181 + if link not in existing_links:
182 + existing_links.append(link)
183 + existing["github_links"] = existing_links
184 + deduped = list(grouped.values())
185 + deduped.sort(
186 + key=lambda item: (
187 + item.get("published_at", ""),
188 + item.get("source", ""),
189 + item.get("url", ""),
190 + item.get("title", ""),
191 + ),
192 + reverse=True,
193 + )
194 + return deduped, duplicates
195 +
196 +
197 +def _article_citation(article: dict[str, Any]) -> dict[str, Any]:
198 + """Return the bounded citation fields downstream renderers are allowed to use."""
199 + return {
200 + "title": article.get("title", ""),
201 + "url": article.get("url", ""),
202 + "source": article.get("source", "unknown"),
203 + "sources": article.get("sources", [article.get("source", "unknown")]),
204 + "published_at": article.get("published_at", ""),
205 + "relevance_score": article.get("relevance_score", 0),
206 + }
207 +
208 +
209 +def _unique_articles(articles: list[dict[str, Any]]) -> list[dict[str, Any]]:
210 + """Return URL-deduped articles preserving order."""
211 + seen: set[str] = set()
212 + unique: list[dict[str, Any]] = []
213 + for article in articles:
214 + key = _normalized_article_url(str(article.get("url", ""))) or str(article)
215 + if key in seen:
216 + continue
217 + seen.add(key)
218 + unique.append(article)
219 + return unique
220 +
221 +
222 +def correlation_strength(
223 + match_type: str,
224 + matched_articles: list[dict[str, Any]],
225 + *,
226 + temporal_spike: bool,
227 +) -> str:
228 + """Label strong vs weak correlations without letting fuzzy/category inflate claims."""
229 + source_names = {
230 + source
231 + for article in matched_articles
232 + for source in article.get("sources", [article.get("source", "unknown")])
233 + }
234 + corroborated = len(source_names) >= 2 or len(_unique_articles(matched_articles)) >= 2
235 + if match_type in WEAK_MATCH_TYPES:
236 + return "weak"
237 + if match_type in {"direct_link", "org_name"} or temporal_spike or corroborated:
238 + return "strong"
239 + return "weak"
240 +
241 +
242 def has_temporal_spike(repo: dict[str, Any], stars_threshold: int = 10) -> bool:
243 """Check if repo had a stars_gained spike in the same week."""
244 stars_gained = repo.get("stars_gained")
@@ -161,13 +275,15 @@ def correlate_repo(repo: dict[str, Any], articles: list[dict[str, Any]]) -> dict
275 best_confidence = 0.0
276 best_type = ""
277 matched_articles: list[str] = []
278 + matched_article_objs: list[dict[str, Any]] = []
279
280 # Priority 1: Direct link match (confidence 1.0)
281 direct = match_direct_link(repo, articles)
282 if direct:
283 best_confidence = 1.0
284 best_type = "direct_link"
170 - matched_articles = [a["url"] for a in direct]
285 + matched_article_objs = _unique_articles(direct)[:MAX_MATCHED_ARTICLES_PER_REPO]
286 + matched_articles = [a["url"] for a in matched_article_objs]
287
288 # Priority 2: Org name match (confidence 0.8)
289 if not matched_articles:
@@ -175,7 +291,8 @@ def correlate_repo(repo: dict[str, Any], articles: list[dict[str, Any]]) -> dict
291 if org:
292 best_confidence = 0.8
293 best_type = "org_name"
178 - matched_articles = [a["url"] for a in org]
294 + matched_article_objs = _unique_articles(org)[:MAX_MATCHED_ARTICLES_PER_REPO]
295 + matched_articles = [a["url"] for a in matched_article_objs]
296
297 # Priority 3: Project name fuzzy match (confidence 0.6)
298 if not matched_articles:
@@ -183,7 +300,8 @@ def correlate_repo(repo: dict[str, Any], articles: list[dict[str, Any]]) -> dict
300 if fuzzy:
301 best_confidence = 0.6
302 best_type = "project_name"
186 - matched_articles = [a["url"] for a in fuzzy]
303 + matched_article_objs = _unique_articles(fuzzy)[:MAX_MATCHED_ARTICLES_PER_REPO]
304 + matched_articles = [a["url"] for a in matched_article_objs]
305
306 # Priority 4: Category correlation (confidence 0.4)
307 if not matched_articles:
@@ -191,23 +309,36 @@ def correlate_repo(repo: dict[str, Any], articles: list[dict[str, Any]]) -> dict
309 if cat:
310 best_confidence = 0.4
311 best_type = "category"
194 - matched_articles = [a["url"] for a in cat]
312 + matched_article_objs = _unique_articles(cat)[:MAX_MATCHED_ARTICLES_PER_REPO]
313 + matched_articles = [a["url"] for a in matched_article_objs]
314
315 if not matched_articles:
316 return None
317
318 # Priority 5: Temporal lag bonus
319 press_correlated = True
201 - if has_temporal_spike(repo):
320 + temporal_spike = has_temporal_spike(repo)
321 + if temporal_spike and best_type not in WEAK_MATCH_TYPES:
322 best_confidence = min(best_confidence + 0.2, 1.0)
323 press_correlated = True
324 + strength = correlation_strength(
325 + best_type,
326 + matched_article_objs,
327 + temporal_spike=temporal_spike,
328 + )
329
330 return {
331 "repo": repo.get("full_name") or f"{repo.get('owner')}/{repo.get('name')}",
332 "press_correlated": press_correlated,
333 "correlation_confidence": round(best_confidence, 2),
334 "matched_articles": matched_articles,
335 + "matched_article_details": [
336 + _article_citation(article) for article in matched_article_objs
337 + ],
338 "match_type": best_type,
339 + "correlation_strength": strength,
340 + "confidence_label": strength,
341 + "temporal_spike": temporal_spike,
342 "hype_risk": assess_hype_risk(best_confidence, repo.get("stars_gained")),
343 }
344
@@ -253,7 +384,10 @@ def detect_divergences(
384 matched_article_urls.update(corr.get("matched_articles", []))
385
386 # Unmatched articles → uncovered tech trends
256 - unmatched_articles = [a for a in articles if a.get("url") not in matched_article_urls]
387 + unmatched_articles = [
388 + a for a in articles
389 + if a.get("url") not in matched_article_urls
390 + ][:MAX_DIVERGENCE_ARTICLES]
391
392 # Group unmatched articles by topic
393 topic_articles: dict[str, list[dict[str, Any]]] = {}
@@ -264,6 +398,10 @@ def detect_divergences(
398 uncovered_tech_trends = [
399 {
400 "topic": topic,
401 + "news_articles": [
402 + {"title": a.get("title", ""), "url": a.get("url", "")}
403 + for a in arts
404 + ],
405 "techcrunch_articles": [
406 {"title": a.get("title", ""), "url": a.get("url", "")}
407 for a in arts
@@ -297,7 +435,7 @@ def detect_divergences(
435 }
436 for r in reps
437 ],
300 - "signal": "No TechCrunch coverage",
438 + "signal": "No external press coverage",
439 }
440 for topic, reps in sorted(topic_repos.items(), key=lambda x: -len(x[1]))
441 ]
@@ -310,6 +448,8 @@ def detect_divergences(
448
449 def correlate_all(repos: list[dict[str, Any]], articles: list[dict[str, Any]], week: str) -> dict[str, Any]:
450 """Run correlation engine across all repos and articles."""
451 + articles, dedupe_count = dedupe_articles(articles)
452 + articles = articles[:MAX_ARTICLES_FOR_CORRELATION]
453 correlations: list[dict[str, Any]] = []
454 uncorrelated: list[str] = []
455
@@ -322,7 +462,14 @@ def correlate_all(repos: list[dict[str, Any]], articles: list[dict[str, Any]], w
462 uncorrelated.append(name)
463
464 # Sort by confidence descending
325 - correlations.sort(key=lambda c: c["correlation_confidence"], reverse=True)
465 + correlations.sort(
466 + key=lambda c: (
467 + c.get("correlation_strength") != "strong",
468 + -c["correlation_confidence"],
469 + c.get("repo", ""),
470 + )
471 + )
472 + correlations = correlations[:MAX_CORRELATIONS]
473
474 articles_matched = len({url for c in correlations for url in c["matched_articles"]})
475
@@ -336,8 +483,23 @@ def correlate_all(repos: list[dict[str, Any]], articles: list[dict[str, Any]], w
483 "uncorrelated_repos": uncorrelated,
484 "metadata": {
485 "repos_analyzed": len(repos),
486 + "articles_analyzed": len(articles),
487 "correlations_found": len(correlations),
488 + "strong_correlations": sum(
489 + 1 for corr in correlations
490 + if corr.get("correlation_strength") == "strong"
491 + ),
492 + "weak_correlations": sum(
493 + 1 for corr in correlations
494 + if corr.get("correlation_strength") == "weak"
495 + ),
496 "articles_matched": articles_matched,
497 + "dedupe_count": dedupe_count,
498 + "limits": {
499 + "max_articles": MAX_ARTICLES_FOR_CORRELATION,
500 + "max_correlations": MAX_CORRELATIONS,
501 + "max_matched_articles_per_repo": MAX_MATCHED_ARTICLES_PER_REPO,
502 + },
503 "uncovered_tech_trends": len(divergences["uncovered_tech_trends"]),
504 "unpublicized_dev_activity": len(divergences["unpublicized_dev_activity"]),
505 },
@@ -363,6 +525,30 @@ def load_json(path: Path) -> dict[str, Any]:
525 return json.load(f)
526
527
528 +def extract_news_metadata(news_data: dict[str, Any] | list[dict[str, Any]]) -> dict[str, Any]:
529 + """Extract source/failure metadata from canonical or legacy news payloads."""
530 + if isinstance(news_data, list):
531 + return {
532 + "schema_version": 1,
533 + "sources_requested": ["techcrunch"],
534 + "sources_succeeded": ["techcrunch"],
535 + "sources_failed": [],
536 + "source_status": [],
537 + "errors": [],
538 + }
539 + metadata = news_data.get("metadata", {})
540 + return {
541 + "schema_version": news_data.get("schema_version", 1),
542 + "source_config_checksum": metadata.get("source_config_checksum", ""),
543 + "sources_requested": metadata.get("sources_requested", [news_data.get("source", "techcrunch")]),
544 + "sources_succeeded": metadata.get("sources_succeeded", []),
545 + "sources_failed": metadata.get("sources_failed", []),
546 + "source_status": metadata.get("source_status", []),
547 + "errors": metadata.get("errors", []),
548 + "artifact_checksum": metadata.get("artifact_checksum", ""),
549 + }
550 +
551 +
552 def extract_week_from_filename(path: Path) -> str:
553 """Extract week slug from filename like '2026-W21.json'."""
554 match = re.search(r"(\d{4}-W\d{2})", path.name)
@@ -433,17 +619,21 @@ def main(argv: list[str] | None = None) -> int:
619
620 # Load articles (graceful if missing)
621 articles: list[dict[str, Any]] = []
622 + news_metadata: dict[str, Any] = {}
623 if tc_path and tc_path.exists():
624 tc_data = load_json(tc_path)
625 + news_metadata = extract_news_metadata(tc_data)
626 articles = tc_data if isinstance(tc_data, list) else tc_data.get("articles", [])
627 else:
440 - log("No TechCrunch data found; producing empty correlations")
628 + log("No external news data found; producing empty correlations")
629
630 # Determine week
631 week = extract_week_from_filename(raw_path)
632
633 # Run correlation
634 result = correlate_all(repos, articles, week)
635 + if news_metadata:
636 + result["metadata"]["news_sources"] = news_metadata
637
638 # Write output
639 if args.output:
scripts/render_press_context.py
+105 -14
@@ -22,6 +22,11 @@ sys.path.insert(0, str(_REPO_ROOT / "scripts"))
22
23 from topic_paths import raw_dir, analyzed_dir # noqa: E402
24
25 +PRESS_CONTEXT_TOKEN_BUDGET = 8000
26 +PRESS_CONTEXT_CHAR_BUDGET = PRESS_CONTEXT_TOKEN_BUDGET * 4
27 +MAX_RENDERED_ARTICLES = 40
28 +MAX_RENDERED_CORRELATIONS = 20
29 +
30
31 def current_week() -> str:
32 """Return the current ISO week as YYYY-WNN."""
@@ -43,15 +48,23 @@ def format_articles_list(articles: list[dict]) -> str:
48 if not articles:
49 return "- (none)"
50 lines = []
46 - for article in articles:
51 + for article in articles[:MAX_RENDERED_ARTICLES]:
52 title = article.get("title", "Untitled")
53 url = article.get("url", "")
54 categories = article.get("categories", [])
55 + source = article.get("source", "unknown")
56 + published_at = article.get("published_at", "")
57 cat_str = f" [{', '.join(categories)}]" if categories else ""
58 + source_str = f" — {source}"
59 + if published_at:
60 + source_str += f", {published_at[:10]}"
61 if url:
52 - lines.append(f"- [{title}]({url}){cat_str}")
62 + lines.append(f"- [{title}]({url}){cat_str}{source_str}")
63 else:
54 - lines.append(f"- {title}{cat_str}")
64 + lines.append(f"- {title}{cat_str}{source_str}")
65 + omitted = len(articles) - MAX_RENDERED_ARTICLES
66 + if omitted > 0:
67 + lines.append(f"…and {omitted} more relevant articles within budget")
68 return "\n".join(lines)
69
70
@@ -206,7 +219,7 @@ def _format_correlations_narrative(
219 arts_str = _join_links(article_links)
220 if idx == 0:
221 para = (
209 - f"This week's TechCrunch coverage closely tracks developer activity "
222 + f"This week's external press coverage closely tracks developer activity "
223 f"across {total} repos. {org.capitalize()} featured prominently: "
224 f"coverage of {arts_str} aligns with activity in {repos_str}."
225 )
@@ -218,7 +231,7 @@ def _format_correlations_narrative(
231 else:
232 if idx == 0:
233 para = (
221 - f"This week's TechCrunch coverage closely tracks developer activity "
234 + f"This week's external press coverage closely tracks developer activity "
235 f"across {total} repos. {org.capitalize()} shows the strongest signal, "
236 f"with {repos_str} seeing notable GitHub traction."
237 )
@@ -282,10 +295,25 @@ def format_correlations_list(
295 repo = corr.get("repo", "unknown")
296 match_type = corr.get("match_type", "unknown")
297 confidence = corr.get("correlation_confidence", 0.0)
298 + strength = corr.get("correlation_strength", corr.get("confidence_label", "unknown"))
299 hype_risk = corr.get("hype_risk", "none")
300 + details = corr.get("matched_article_details", [])
301 + sources = sorted({
302 + source
303 + for detail in details
304 + for source in detail.get("sources", [detail.get("source", "unknown")])
305 + })
306 + citation = ""
307 + if details:
308 + first = details[0]
309 + title = first.get("title", "article")
310 + url = first.get("url", "")
311 + citation = f", cited: [{title}]({url})" if url else f", cited: {title}"
312 lines.append(
313 f"- {repo} — match: {match_type}, "
288 - f"confidence: {confidence:.1f}, hype_risk: {hype_risk}"
314 + f"strength: {strength}, confidence: {confidence:.1f}, "
315 + f"sources: {', '.join(sources) if sources else 'unknown'}, "
316 + f"hype_risk: {hype_risk}{citation}"
317 )
318
319 if omitted > 0:
@@ -378,7 +406,7 @@ def _format_uncovered_narrative(items: list[dict]) -> str:
406 # Collect up to two article links across all topics
407 article_links: list[str] = []
408 for item in display:
381 - for a in item.get("techcrunch_articles", [])[:1]:
409 + for a in item.get("news_articles", item.get("techcrunch_articles", []))[:1]:
410 title = a.get("title", "article")
411 url = a.get("url", "")
412 if url:
@@ -404,7 +432,7 @@ def _format_uncovered_narrative(items: list[dict]) -> str:
432 article_str = "Press articles generated buzz"
433
434 return (
407 - f"TechCrunch heavily covered {topics_str} this week, but GitHub shows minimal "
435 + f"External press heavily covered {topics_str} this week, but GitHub shows minimal "
436 f"matching developer activity. {article_str}, yet no significant new repositories "
437 f"emerged in these spaces — suggesting these are still in the narrative or "
438 f"announcement phase rather than implementation."
@@ -446,10 +474,10 @@ def format_divergences(divergences: dict, *, reader_mode: bool = False) -> str:
474 # AI prompt mode: full raw data for model consumption — keep unchanged
475 if uncovered:
476 lines.append("#### 🔍 Tech Trends Without Dev Activity")
449 - lines.append("Topics heavily covered by TechCrunch with no matching GitHub repos:\n")
477 + lines.append("Topics heavily covered by external press with no matching GitHub repos:\n")
478 for item in uncovered:
479 topic = item.get("topic", "unknown")
452 - articles = item.get("techcrunch_articles", [])
480 + articles = item.get("news_articles", item.get("techcrunch_articles", []))
481 article_refs = ", ".join(
482 f"[{a.get('title', 'article')}]({a.get('url', '')})"
483 for a in articles[:3]
@@ -459,7 +487,7 @@ def format_divergences(divergences: dict, *, reader_mode: bool = False) -> str:
487
488 if unpublicized:
489 lines.append("#### 🚀 Dev Activity Without Press Coverage")
462 - lines.append("GitHub repos/trends with no matching TechCrunch coverage:\n")
490 + lines.append("GitHub repos/trends with no matching external press coverage:\n")
491 for item in unpublicized:
492 topic = item.get("topic", "unknown")
493 repos = item.get("github_repos", [])
@@ -479,6 +507,55 @@ def format_divergences(divergences: dict, *, reader_mode: bool = False) -> str:
507 return "\n".join(lines)
508
509
510 +def _source_caveats(techcrunch_data: dict | None, correlation_data: dict | None) -> str:
511 + """Render concise partial-failure caveats from crawl/correlation metadata."""
512 + metadata: dict = {}
513 + if techcrunch_data:
514 + metadata = techcrunch_data.get("metadata", {})
515 + corr_sources = {}
516 + if correlation_data:
517 + corr_sources = correlation_data.get("metadata", {}).get("news_sources", {})
518 +
519 + requested = metadata.get("sources_requested") or corr_sources.get("sources_requested") or []
520 + succeeded = metadata.get("sources_succeeded") or corr_sources.get("sources_succeeded") or []
521 + failed = metadata.get("sources_failed") or corr_sources.get("sources_failed") or []
522 + errors = metadata.get("errors") or corr_sources.get("errors") or []
523 + if not requested and not failed:
524 + return ""
525 + lines = [
526 + "### Source Coverage",
527 + f"- Sources requested: {', '.join(requested) if requested else 'unknown'}",
528 + f"- Sources succeeded: {', '.join(succeeded) if succeeded else 'none'}",
529 + ]
530 + if failed:
531 + lines.append(f"- Partial crawl caveat: failed sources: {', '.join(failed)}")
532 + for error in errors[:3]:
533 + lines.append(
534 + f" - {error.get('source', 'unknown')}: "
535 + f"{error.get('error_class', 'error')} {error.get('error', '')}".strip()
536 + )
537 + return "\n".join(lines)
538 +
539 +
540 +def estimate_tokens(markdown: str) -> int:
541 + """Return a rough token estimate used for telemetry and hard budget checks."""
542 + return max(1, (len(markdown) + 3) // 4)
543 +
544 +
545 +def enforce_press_context_budget(markdown: str) -> str:
546 + """Keep press context below the documented token budget."""
547 + if estimate_tokens(markdown) <= PRESS_CONTEXT_TOKEN_BUDGET:
548 + return markdown
549 + budget_note = (
550 + "\n\n### Budget Notice\n"
551 + f"Press context truncated to ~{PRESS_CONTEXT_TOKEN_BUDGET} tokens; "
552 + "citations and source caveats above are prioritized.\n"
553 + )
554 + keep_chars = max(0, PRESS_CONTEXT_CHAR_BUDGET - len(budget_note))
555 + truncated = markdown[:keep_chars].rsplit("\n", 1)[0]
556 + return truncated + budget_note
557 +
558 +
559 def render_press_context(
560 techcrunch_data: dict | None,
561 correlation_data: dict | None,
@@ -539,10 +616,12 @@ def render_press_context(
616 ),
617 )
618
542 - top_n = 10 if reader_mode else None
619 + top_n = MAX_RENDERED_CORRELATIONS if reader_mode else None
620
621 # Render template
545 - rendered = template.replace("{date}", week)
622 + source_label = "External news"
623 + rendered = template.replace("TechCrunch", source_label)
624 + rendered = rendered.replace("{date}", week)
625 rendered = rendered.replace("{article_count}", str(article_count))
626 rendered = rendered.replace("{articles_list}", format_articles_list(articles))
627 rendered = rendered.replace("{correlation_count}", str(correlation_count))
@@ -570,7 +649,19 @@ def render_press_context(
649 if divergence_section:
650 rendered += "\n" + divergence_section
651
573 - return rendered
652 + caveats = _source_caveats(techcrunch_data, correlation_data)
653 + if caveats:
654 + rendered += "\n\n" + caveats
655 +
656 + rendered += (
657 + "\n\n### Press Context Telemetry\n"
658 + f"- token_estimate: {estimate_tokens(rendered)}\n"
659 + f"- token_budget: {PRESS_CONTEXT_TOKEN_BUDGET}\n"
660 + f"- article_limit: {MAX_RENDERED_ARTICLES}\n"
661 + f"- correlation_limit: {MAX_RENDERED_CORRELATIONS if reader_mode else 'unbounded-input'}\n"
662 + )
663 +
664 + return enforce_press_context_budget(rendered)
665
666
667 def resolve_paths(topic: str | None, week: str) -> tuple[Path, Path]:
scripts/techcrunch_crawler.py
+267 -15
@@ -12,6 +12,7 @@ Usage:
12 from __future__ import annotations
13
14 import argparse
15 +import hashlib
16 import ipaddress
17 import json
18 import re
@@ -33,7 +34,9 @@ from scripts.topic_paths import raw_dir
34 FEED_URL = "https://techcrunch.com/feed/"
35 DEFAULT_SOURCES_PATH = Path("config/external_news_sources.json")
36 DEFAULT_FETCH_TIMEOUT_SECONDS = 15
37 +DEFAULT_FETCH_RETRIES = 1
38 DEFAULT_MAX_WORKERS = 8
39 +CANONICAL_SCHEMA_VERSION = 2
40 APPROVED_FEED_HOSTS = frozenset({
41 "techcrunch.com",
42 "blogs.nvidia.com",
@@ -83,6 +86,11 @@ class NewsSourceConfig:
86 def __post_init__(self) -> None:
87 validate_feed_url(self.feed_url)
88
89 + @property
90 + def host(self) -> str:
91 + """Return the normalized feed host."""
92 + return (urlparse(self.feed_url).hostname or "").rstrip(".").lower()
93 +
94
95 def validate_feed_url(url: str) -> None:
96 """Validate an external RSS URL against the approved egress allowlist."""
@@ -144,6 +152,20 @@ def load_source_configs(path: Path = DEFAULT_SOURCES_PATH) -> list[NewsSourceCon
152 return sources
153
154
155 +def source_config_checksum(sources: list[NewsSourceConfig]) -> str:
156 + """Return a stable checksum for the effective source config."""
157 + canonical = [
158 + {
159 + "feed_url": source.feed_url,
160 + "name": source.name,
161 + "requests_per_minute": source.requests_per_minute,
162 + }
163 + for source in sorted(sources, key=lambda item: item.name)
164 + ]
165 + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
166 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
167 +
168 +
169 def iso_timestamp(value: datetime) -> str:
170 """Format datetime as ISO 8601 UTC string."""
171 return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
@@ -228,7 +250,7 @@ def parse_published_date(entry: Any) -> datetime | None:
250
251 def fetch_feed(
252 url: str = FEED_URL,
231 - retries: int = 1,
253 + retries: int = DEFAULT_FETCH_RETRIES,
254 timeout: int = DEFAULT_FETCH_TIMEOUT_SECONDS,
255 ) -> Any:
256 """Fetch and parse RSS feed with bounded retries and an explicit timeout."""
@@ -240,6 +262,8 @@ def fetch_feed(
262 request = Request(url, headers={"User-Agent": "SquadScope RSS crawler"})
263 with urlopen(request, timeout=timeout) as response:
264 feed = feedparser.parse(response.read())
265 + setattr(feed, "squad_fetch_attempts", attempt + 1)
266 + setattr(feed, "squad_fetch_timeout_seconds", timeout)
267 except Exception:
268 if attempt < retries:
269 time.sleep(2)
@@ -250,6 +274,8 @@ def fetch_feed(
274 time.sleep(2)
275 continue
276 # Return partial result even on failure
277 + setattr(feed, "squad_fetch_attempts", attempt + 1)
278 + setattr(feed, "squad_fetch_timeout_seconds", timeout)
279 return feed
280 return feed
281 return feed # pragma: no cover
@@ -260,6 +286,8 @@ class NewsFeedSource:
286
287 def __init__(self, config: NewsSourceConfig) -> None:
288 self.config = config
289 + self.last_attempts = 0
290 + self.last_timeout_seconds = DEFAULT_FETCH_TIMEOUT_SECONDS
291
292 def get_name(self) -> str:
293 return self.config.name
@@ -277,6 +305,10 @@ class NewsFeedSource:
305 resolved_feed_url = feed_url or self.config.feed_url
306 validate_feed_url(resolved_feed_url)
307 feed = fetch_feed(resolved_feed_url)
308 + self.last_attempts = int(getattr(feed, "squad_fetch_attempts", 1))
309 + self.last_timeout_seconds = int(
310 + getattr(feed, "squad_fetch_timeout_seconds", DEFAULT_FETCH_TIMEOUT_SECONDS)
311 + )
312 articles: list[dict[str, Any]] = []
313
314 for entry in feed.entries:
@@ -332,33 +364,186 @@ def crawl_sources_parallel(
364 since: datetime,
365 until: datetime,
366 max_workers: int | None = None,
335 -) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
336 - """Crawl configured RSS sources concurrently and return articles plus errors."""
367 +) -> tuple[list[dict[str, Any]], list[dict[str, str]], list[dict[str, Any]]]:
368 + """Crawl configured RSS sources concurrently and return articles, errors, statuses."""
369 if not sources:
338 - return [], []
370 + return [], [], []
371
372 if max_workers is not None and max_workers < 1:
373 raise ValueError("--max-workers must be at least 1")
374 workers = min(max_workers or len(sources), len(sources), DEFAULT_MAX_WORKERS)
375 articles: list[dict[str, Any]] = []
376 errors: list[dict[str, str]] = []
377 + statuses: list[dict[str, Any]] = []
378 +
379 + def crawl_one(source: NewsSourceConfig) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, str] | None]:
380 + started = datetime.now(UTC)
381 + source_client = NewsFeedSource(source)
382 + status: dict[str, Any] = {
383 + "source": source.name,
384 + "host": source.host,
385 + "started_at": iso_timestamp(started),
386 + "timeout_seconds": DEFAULT_FETCH_TIMEOUT_SECONDS,
387 + "attempts": 0,
388 + "total_articles": 0,
389 + "relevant_articles": 0,
390 + "github_links_found": 0,
391 + "success": False,
392 + "error_class": "",
393 + "error_message": "",
394 + }
395 + try:
396 + source_articles = source_client.crawl(since, until)
397 + status["attempts"] = source_client.last_attempts or 1
398 + status["timeout_seconds"] = source_client.last_timeout_seconds
399 + status["total_articles"] = len(source_articles)
400 + status["relevant_articles"] = sum(
401 + 1 for article in source_articles
402 + if article.get("relevance_score", 0) >= 0.4
403 + )
404 + github_links: set[str] = set()
405 + for article in source_articles:
406 + github_links.update(article.get("github_links", []))
407 + status["github_links_found"] = len(github_links)
408 + status["success"] = True
409 + return source_articles, status, None
410 + except Exception as exc: # pragma: no cover - defensive around network/parser failures
411 + status["attempts"] = source_client.last_attempts or (DEFAULT_FETCH_RETRIES + 1)
412 + status["error_class"] = exc.__class__.__name__
413 + status["error_message"] = str(exc)
414 + error = {
415 + "source": source.name,
416 + "error_class": exc.__class__.__name__,
417 + "error": str(exc),
418 + }
419 + return [], status, error
420 + finally:
421 + ended = datetime.now(UTC)
422 + status["ended_at"] = iso_timestamp(ended)
423 + status["duration_seconds"] = round((ended - started).total_seconds(), 3)
424 +
425 with ThreadPoolExecutor(max_workers=workers) as executor:
426 futures = {
347 - executor.submit(NewsFeedSource(source).crawl, since, until): source
427 + executor.submit(crawl_one, source): source
428 for source in sources
429 }
430 for future in as_completed(futures):
351 - source = futures[future]
352 - try:
353 - articles.extend(future.result())
354 - except Exception as exc: # pragma: no cover - defensive around network/parser failures
355 - errors.append({"source": source.name, "error": str(exc)})
431 + source_articles, status, error = future.result()
432 + articles.extend(source_articles)
433 + statuses.append(status)
434 + if error:
435 + errors.append(error)
436
437 articles.sort(
358 - key=lambda article: (article.get("published_at", ""), article.get("source", "")),
438 + key=lambda article: (
439 + article.get("published_at", ""),
440 + article.get("source", ""),
441 + article.get("url", ""),
442 + article.get("title", ""),
443 + ),
444 + reverse=True,
445 + )
446 + statuses.sort(key=lambda status: status["source"])
447 + errors.sort(key=lambda error: error["source"])
448 + for status in statuses:
449 + state = "ok" if status["success"] else f"failed:{status['error_class']}"
450 + print(
451 + "[external-news] "
452 + f"{status['source']} host={status['host']} status={state} "
453 + f"duration={status['duration_seconds']:.3f}s attempts={status['attempts']} "
454 + f"articles={status['total_articles']} relevant={status['relevant_articles']} "
455 + f"github_links={status['github_links_found']}",
456 + file=sys.stderr,
457 + )
458 + return articles, errors, statuses
459 +
460 +
461 +def _normalized_article_url(url: str) -> str:
462 + """Normalize a URL for cross-source dedupe."""
463 + if not url:
464 + return ""
465 + parsed = urlparse(url.strip())
466 + host = (parsed.netloc or "").lower()
467 + path = parsed.path.rstrip("/")
468 + return f"{parsed.scheme.lower()}://{host}{path}"
469 +
470 +
471 +def dedupe_articles(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
472 + """Deduplicate mirrored articles by URL while preserving provenance."""
473 + grouped: dict[str, dict[str, Any]] = {}
474 + duplicates = 0
475 + for article in sorted(
476 + articles,
477 + key=lambda item: (
478 + item.get("published_at", ""),
479 + item.get("source", ""),
480 + item.get("url", ""),
481 + item.get("title", ""),
482 + ),
483 + reverse=True,
484 + ):
485 + key = _normalized_article_url(str(article.get("url", "")))
486 + if not key:
487 + key = "|".join([
488 + str(article.get("source", "")),
489 + str(article.get("published_at", "")),
490 + str(article.get("title", "")).lower(),
491 + ])
492 + if key not in grouped:
493 + current = dict(article)
494 + current["sources"] = sorted({
495 + str(article.get("source", "")) or "unknown",
496 + *[str(s) for s in article.get("sources", [])],
497 + })
498 + grouped[key] = current
499 + continue
500 + duplicates += 1
501 + existing = grouped[key]
502 + existing_sources = set(existing.get("sources", []))
503 + existing_sources.add(str(article.get("source", "")) or "unknown")
504 + existing_sources.update(str(s) for s in article.get("sources", []))
505 + existing["sources"] = sorted(existing_sources)
506 + existing["relevance_score"] = max(
507 + float(existing.get("relevance_score", 0)),
508 + float(article.get("relevance_score", 0)),
509 + )
510 + existing_links = list(existing.get("github_links", []))
511 + for link in article.get("github_links", []):
512 + if link not in existing_links:
513 + existing_links.append(link)
514 + existing["github_links"] = existing_links
515 + deduped = list(grouped.values())
516 + deduped.sort(
517 + key=lambda article: (
518 + article.get("published_at", ""),
519 + article.get("source", ""),
520 + article.get("url", ""),
521 + article.get("title", ""),
522 + ),
523 reverse=True,
524 )
361 - return articles, errors
525 + return deduped, duplicates
526 +
527 +
528 +def _checksum_payload(output: dict[str, Any]) -> dict[str, Any]:
529 + """Return the deterministic subset covered by artifact_checksum."""
530 + metadata = dict(output.get("metadata", {}))
531 + metadata.pop("artifact_checksum", None)
532 + payload = dict(output)
533 + payload["metadata"] = metadata
534 + payload.pop("crawled_at", None)
535 + return payload
536 +
537 +
538 +def artifact_checksum(output: dict[str, Any]) -> str:
539 + """Return a stable checksum for the canonical artifact content."""
540 + payload = json.dumps(
541 + _checksum_payload(output),
542 + sort_keys=True,
543 + separators=(",", ":"),
544 + ensure_ascii=False,
545 + )
546 + return hashlib.sha256(payload.encode("utf-8")).hexdigest()
547
548
549 def build_output(
@@ -367,29 +552,88 @@ def build_output(
552 *,
553 source: str = "techcrunch",
554 source_count: int = 1,
555 + crawl_window: dict[str, str] | None = None,
556 + source_config_checksum_value: str | None = None,
557 + requested_sources: list[str] | None = None,
558 + source_statuses: list[dict[str, Any]] | None = None,
559 errors: list[dict[str, str]] | None = None,
560 ) -> dict[str, Any]:
561 """Build the final output structure with metadata."""
562 + articles, dedupe_count = dedupe_articles(articles)
563 relevant = [a for a in articles if a["relevance_score"] >= 0.4]
564 all_github_links = set()
565 for a in articles:
566 all_github_links.update(a.get("github_links", []))
567
568 + statuses = source_statuses or []
569 + succeeded = [
570 + str(status.get("source"))
571 + for status in statuses
572 + if status.get("success")
573 + ]
574 + failed = [
575 + str(status.get("source"))
576 + for status in statuses
577 + if not status.get("success")
578 + ]
579 + requested = requested_sources or sorted({
580 + str(article.get("source", source)) for article in articles
581 + }) or [source]
582 by_source = Counter(str(article.get("source", source)) for article in articles)
379 - return {
583 + output = {
584 + "schema_version": CANONICAL_SCHEMA_VERSION,
585 "week": week_slug(crawled_at),
586 "source": source,
587 "crawled_at": iso_timestamp(crawled_at),
588 + "crawl_window": crawl_window or {},
589 "articles": articles,
590 "metadata": {
591 "source_count": source_count,
592 + "source_config_checksum": source_config_checksum_value or "",
593 + "sources_requested": sorted(requested),
594 + "sources_succeeded": sorted(succeeded or requested),
595 + "sources_failed": sorted(failed),
596 + "source_status": sorted(statuses, key=lambda status: status["source"]),
597 "sources_with_articles": dict(sorted(by_source.items())),
598 "total_articles": len(articles),
599 "relevant_articles": len(relevant),
600 "github_links_found": len(all_github_links),
601 + "dedupe_count": dedupe_count,
602 "errors": errors or [],
603 },
604 }
605 + output["metadata"]["artifact_checksum"] = artifact_checksum(output)
606 + validate_canonical_output(output)
607 + return output
608 +
609 +
610 +def validate_canonical_output(output: dict[str, Any]) -> None:
611 + """Validate canonical external-news artifact shape."""
612 + if output.get("schema_version") != CANONICAL_SCHEMA_VERSION:
613 + raise ValueError("External news artifact has unsupported schema_version")
614 + if output.get("source") == "external_news" and not output.get("crawl_window"):
615 + raise ValueError("Canonical external news artifact requires crawl_window")
616 + metadata = output.get("metadata")
617 + if not isinstance(metadata, dict):
618 + raise ValueError("External news artifact requires metadata")
619 + required = {
620 + "source_config_checksum",
621 + "sources_requested",
622 + "sources_succeeded",
623 + "sources_failed",
624 + "source_status",
625 + "total_articles",
626 + "relevant_articles",
627 + "dedupe_count",
628 + "errors",
629 + "artifact_checksum",
630 + }
631 + missing = sorted(required - set(metadata))
632 + if missing:
633 + raise ValueError(f"External news artifact missing metadata keys: {missing}")
634 + expected_checksum = artifact_checksum(output)
635 + if metadata.get("artifact_checksum") != expected_checksum:
636 + raise ValueError("External news artifact checksum mismatch")
637
638
639 def main(argv: list[str] | None = None) -> int:
@@ -438,7 +682,7 @@ def main(argv: list[str] | None = None) -> int:
682 )
683
684 source_configs = load_source_configs(Path(args.sources))
441 - articles, errors = crawl_sources_parallel(
685 + articles, errors, statuses = crawl_sources_parallel(
686 source_configs, since=since, until=until, max_workers=args.max_workers
687 )
688 output = build_output(
@@ -446,6 +690,13 @@ def main(argv: list[str] | None = None) -> int:
690 crawled_at=now,
691 source="external_news",
692 source_count=len(source_configs),
693 + crawl_window={
694 + "since": iso_timestamp(since),
695 + "until": iso_timestamp(until),
696 + },
697 + source_config_checksum_value=source_config_checksum(source_configs),
698 + requested_sources=[source.name for source in source_configs],
699 + source_statuses=statuses,
700 errors=errors,
701 )
702
@@ -462,7 +713,8 @@ def main(argv: list[str] | None = None) -> int:
713
714 print(f"Crawled {output['metadata']['total_articles']} articles "
715 f"from {output['metadata']['source_count']} sources "
465 - f"({output['metadata']['relevant_articles']} relevant) → {out_path}")
716 + f"({output['metadata']['relevant_articles']} relevant, "
717 + f"{output['metadata']['dedupe_count']} deduped) → {out_path}")
718 return 0
719
720
tests/test_correlate.py
+55
@@ -11,6 +11,7 @@ from scripts.correlate import (
11 assess_hype_risk,
12 correlate_all,
13 correlate_repo,
14 + dedupe_articles,
15 extract_week_from_filename,
16 fuzzy_name_score,
17 has_temporal_spike,
@@ -54,13 +55,18 @@ def _article(
55 github_links: list[str] | None = None,
56 entities: list[str] | None = None,
57 categories: list[str] | None = None,
58 + source: str = "techcrunch",
59 + published_at: str = "2026-05-15T10:00:00Z",
60 ) -> dict:
61 return {
62 + "source": source,
63 "title": title,
64 "url": url,
65 + "published_at": published_at,
66 "github_links": github_links or [],
67 "entities": entities or [],
68 "categories": categories or [],
69 + "relevance_score": 0.8,
70 }
71
72
@@ -224,6 +230,8 @@ class TestCorrelateRepo:
230 assert result is not None
231 assert result["match_type"] == "direct_link"
232 assert result["correlation_confidence"] == 1.0
233 + assert result["correlation_strength"] == "strong"
234 + assert result["matched_article_details"][0]["source"] == "techcrunch"
235
236 def test_no_match_returns_none(self):
237 repo = _repo(owner="nobody", name="nothing", topics=[])
@@ -237,6 +245,25 @@ class TestCorrelateRepo:
245 assert result is not None
246 assert result["correlation_confidence"] == 1.0 # 0.8 + 0.2
247
248 + def test_category_only_match_is_weak(self):
249 + repo = _repo(owner="acme", name="tool", topics=["ai"], stars_gained=0)
250 + article = _article(categories=["ai"], entities=[])
251 + result = correlate_repo(repo, [article])
252 + assert result is not None
253 + assert result["match_type"] == "category"
254 + assert result["correlation_strength"] == "weak"
255 + assert result["correlation_confidence"] == 0.4
256 +
257 + def test_corroborated_category_match_stays_weak(self):
258 + repo = _repo(owner="acme", name="tool", topics=["ai"], stars_gained=50)
259 + articles = [
260 + _article(url="https://example.com/a", categories=["ai"], entities=[], source="alpha"),
261 + _article(url="https://example.com/b", categories=["ai"], entities=[], source="beta"),
262 + ]
263 + result = correlate_repo(repo, articles)
264 + assert result is not None
265 + assert result["correlation_strength"] == "weak"
266 +
267
268 # ---------------------------------------------------------------------------
269 # Integration: correlate_all
@@ -257,6 +284,8 @@ class TestCorrelateAll:
284 assert "nobody/unrelated" in result["uncorrelated_repos"]
285 assert result["metadata"]["repos_analyzed"] == 2
286 assert result["metadata"]["correlations_found"] == 1
287 + assert result["metadata"]["strong_correlations"] == 1
288 + assert result["metadata"]["weak_correlations"] == 0
289
290 def test_empty_articles(self):
291 repos = [_repo()]
@@ -264,6 +293,24 @@ class TestCorrelateAll:
293 assert result["correlations"] == []
294 assert len(result["uncorrelated_repos"]) == 1
295
296 + def test_cross_source_dedupe(self):
297 + articles = [
298 + _article(
299 + url="https://example.com/story/",
300 + source="alpha",
301 + github_links=["https://github.com/acme/cool-project"],
302 + ),
303 + _article(
304 + url="https://example.com/story",
305 + source="beta",
306 + github_links=["https://github.com/acme/cool-project"],
307 + ),
308 + ]
309 + result = correlate_all([_repo()], articles, "2026-W21")
310 + assert result["metadata"]["dedupe_count"] == 1
311 + details = result["correlations"][0]["matched_article_details"][0]
312 + assert details["sources"] == ["alpha", "beta"]
313 +
314
315 # ---------------------------------------------------------------------------
316 # Utility functions
@@ -285,6 +332,14 @@ class TestUtilities:
332 def test_fuzzy_name_score_empty(self):
333 assert fuzzy_name_score("", "something") == 0.0
334
335 + def test_dedupe_articles_preserves_provenance(self):
336 + articles, count = dedupe_articles([
337 + _article(url="https://example.com/a/", source="alpha"),
338 + _article(url="https://example.com/a", source="beta"),
339 + ])
340 + assert count == 1
341 + assert articles[0]["sources"] == ["alpha", "beta"]
342 +
343
344 # ---------------------------------------------------------------------------
345 # CLI: main() repo loading from crawl output format
tests/test_render_press_context.py
+1 -1
@@ -281,7 +281,7 @@ class TestFormatDivergencesReaderMode:
281 def test_reader_mode_has_narrative(self):
282 result = format_divergences(self._divergences(), reader_mode=True)
283 # Narrative prose paragraphs, no raw bullet lists
284 - assert "TechCrunch heavily covered" in result
284 + assert "External press heavily covered" in result
285 assert "Developer activity this week" in result
286 assert "- **quantum-computing**:" not in result
287 assert "- **wasm-tooling**:" not in result
tests/test_techcrunch_crawler.py
+104 -1
@@ -12,11 +12,13 @@ import pytest
12 from scripts.techcrunch_crawler import (
13 DEFAULT_SOURCES_PATH,
14 DEFAULT_FETCH_TIMEOUT_SECONDS,
15 + DEFAULT_FETCH_RETRIES,
16 NewsSourceConfig,
17 TechCrunchSource,
18 build_output,
19 compute_relevance_score,
20 crawl_sources_parallel,
21 + dedupe_articles,
22 extract_entities,
23 extract_github_urls,
24 fetch_feed,
@@ -388,7 +390,7 @@ class TestExternalNewsSources:
390 NewsSourceConfig("beta", "https://github.blog/feed/"),
391 ]
392 with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=fake_fetch):
391 - articles, errors = crawl_sources_parallel(
393 + articles, errors, statuses = crawl_sources_parallel(
394 sources,
395 since=datetime(2026, 5, 10, tzinfo=UTC),
396 until=datetime(2026, 5, 20, tzinfo=UTC),
@@ -396,6 +398,8 @@ class TestExternalNewsSources:
398 )
399
400 assert errors == []
401 + assert {status["source"] for status in statuses} == {"alpha", "beta"}
402 + assert all(status["success"] for status in statuses)
403 assert [article["source"] for article in articles] == ["beta", "alpha"]
404 assert {article["title"] for article in articles} == {
405 "Alpha AI framework",
@@ -426,10 +430,109 @@ class TestExternalNewsSources:
430 crawled_at=now,
431 source="external_news",
432 source_count=2,
433 + crawl_window={
434 + "since": "2026-05-12T00:00:00Z",
435 + "until": "2026-05-19T00:00:00Z",
436 + },
437 + source_config_checksum_value="abc123",
438 + requested_sources=["alpha", "beta", "gamma"],
439 + source_statuses=[
440 + {
441 + "source": "alpha",
442 + "host": "techcrunch.com",
443 + "success": True,
444 + "attempts": 1,
445 + "timeout_seconds": 15,
446 + "total_articles": 1,
447 + "relevant_articles": 1,
448 + "github_links_found": 0,
449 + "started_at": "2026-05-19T10:00:00Z",
450 + "ended_at": "2026-05-19T10:00:01Z",
451 + "duration_seconds": 1.0,
452 + "error_class": "",
453 + "error_message": "",
454 + },
455 + {
456 + "source": "beta",
457 + "host": "github.blog",
458 + "success": True,
459 + "attempts": 1,
460 + "timeout_seconds": 15,
461 + "total_articles": 1,
462 + "relevant_articles": 1,
463 + "github_links_found": 0,
464 + "started_at": "2026-05-19T10:00:00Z",
465 + "ended_at": "2026-05-19T10:00:01Z",
466 + "duration_seconds": 1.0,
467 + "error_class": "",
468 + "error_message": "",
469 + },
470 + {
471 + "source": "gamma",
472 + "host": "example.com",
473 + "success": False,
474 + "attempts": 2,
475 + "timeout_seconds": 15,
476 + "total_articles": 0,
477 + "relevant_articles": 0,
478 + "github_links_found": 0,
479 + "started_at": "2026-05-19T10:00:00Z",
480 + "ended_at": "2026-05-19T10:00:01Z",
481 + "duration_seconds": 1.0,
482 + "error_class": "TimeoutError",
483 + "error_message": "timeout",
484 + },
485 + ],
486 errors=[{"source": "gamma", "error": "timeout"}],
487 )
488
489 + assert output["schema_version"] == 2
490 assert output["source"] == "external_news"
491 assert output["metadata"]["source_count"] == 2
492 + assert output["metadata"]["source_config_checksum"] == "abc123"
493 + assert output["metadata"]["sources_requested"] == ["alpha", "beta", "gamma"]
494 + assert output["metadata"]["sources_succeeded"] == ["alpha", "beta"]
495 + assert output["metadata"]["sources_failed"] == ["gamma"]
496 + assert output["metadata"]["artifact_checksum"]
497 assert output["metadata"]["sources_with_articles"] == {"alpha": 1, "beta": 1}
498 assert output["metadata"]["errors"] == [{"source": "gamma", "error": "timeout"}]
499 +
500 + def test_dedupe_articles_preserves_sources(self):
501 + articles, deduped = dedupe_articles([
502 + {
503 + "source": "alpha",
504 + "title": "Same story",
505 + "url": "https://example.com/story/",
506 + "published_at": "2026-05-15T10:00:00Z",
507 + "github_links": ["https://github.com/a/b"],
508 + "relevance_score": 0.4,
509 + },
510 + {
511 + "source": "beta",
512 + "title": "Same story mirror",
513 + "url": "https://example.com/story",
514 + "published_at": "2026-05-15T10:00:00Z",
515 + "github_links": ["https://github.com/c/d"],
516 + "relevance_score": 0.8,
517 + },
518 + ])
519 +
520 + assert deduped == 1
521 + assert len(articles) == 1
522 + assert articles[0]["sources"] == ["alpha", "beta"]
523 + assert articles[0]["relevance_score"] == 0.8
524 +
525 + def test_failed_source_reports_bounded_retry_attempts(self):
526 + source = NewsSourceConfig("alpha", "https://techcrunch.com/feed/")
527 + with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=TimeoutError("boom")):
528 + articles, errors, statuses = crawl_sources_parallel(
529 + [source],
530 + since=datetime(2026, 5, 10, tzinfo=UTC),
531 + until=datetime(2026, 5, 20, tzinfo=UTC),
532 + max_workers=1,
533 + )
534 +
535 + assert articles == []
536 + assert errors[0]["error_class"] == "TimeoutError"
537 + assert statuses[0]["attempts"] == DEFAULT_FETCH_RETRIES + 1
538 + assert statuses[0]["success"] is False