feat: add parallel external news feeds
Closes #234
Juan Manuel Servera committed
Jun 5, 2026 at 18:13 UTC
87e55a227da78b86e9677acc96460968196e9e5a
15 files changed
+446
-36
.github/workflows/crawl-and-publish.yml
+9
-4
@@ -104,13 +104,14 @@ jobs:
104
- name: Install Python dependencies
105
run: pip install -r requirements.txt
106
107
- - name: Crawl TechCrunch RSS
107
+ - name: Crawl external news RSS feeds
108
if: ${{ !inputs.rebuild_week }}
109
run: |
110
WEEK=$(date +%Y-W%V)
111
SINCE=$(date -d '7 days ago' +%Y-%m-%d)
112
python scripts/techcrunch_crawler.py \
113
- --output "data/raw/${WEEK}-techcrunch.json" \
113
+ --sources config/external_news_sources.json \
114
+ --output "data/raw/${WEEK}-external-news.json" \
115
--since "$SINCE"
116
117
- name: Upload raw crawl artifact
@@ -212,6 +213,7 @@ jobs:
213
git fetch origin publish
214
mkdir -p data/raw data/analyzed data/snapshots
215
git checkout origin/publish -- "data/raw/${REBUILD_WEEK}.json"
216
+ git checkout origin/publish -- "data/raw/${REBUILD_WEEK}-external-news.json" 2>/dev/null || true
217
git checkout origin/publish -- "data/raw/${REBUILD_WEEK}-techcrunch.json" 2>/dev/null || true
218
git checkout origin/publish -- "data/snapshots/${REBUILD_WEEK}-stars.json" 2>/dev/null || true
219
# Also hydrate ALL prior analyzed files so rollups have correct historical context
@@ -276,9 +278,12 @@ jobs:
278
set -euo pipefail
279
WEEK="${{ steps.analysis-context.outputs.week }}"
280
WEEK_FILE="${{ steps.analysis-context.outputs.week_file }}"
279
- TC_FILE="data/raw/${WEEK}-techcrunch.json"
281
+ TC_FILE="data/raw/${WEEK}-external-news.json"
282
+ if [ ! -f "$TC_FILE" ]; then
283
+ TC_FILE="data/raw/${WEEK}-techcrunch.json"
284
+ fi
285
281
- # Run correlate if TechCrunch data exists
286
+ # Run correlate if external news data exists
287
if [ -f "$TC_FILE" ]; then
288
mkdir -p data/analyzed
289
python scripts/correlate.py \
.squad/agents/bender/history.md
+2
@@ -15,3 +15,5 @@
15
- GitHub Actions schedule events do not have an `inputs` object; use `!inputs.X` instead of `github.event.inputs.X == ''` to safely check optional manual inputs without breaking cron triggers (critical fix, PR #164).
16
- Deploy pipeline should hydrate previous-week content/data from publish branch before Hugo build to prevent main/publish divergence and preserve existing data integrity (PR #164, W21 rescue architectural fix).
17
- Fork-safe deploy secrets should default to empty in Hugo config and be injected via `HUGO_PARAMS_*` environment overrides so forks render safe defaults without inherited maintainer secrets (GA4 PR #182/#191).
18
+- 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).
19
+- Local validation docs for scripts importing `scripts.*` modules should use `python3 -m ...` or set `PYTHONPATH=.` so repo-root imports resolve reliably (PR #236).
.squad/agents/leela/history.md
+1
@@ -5,6 +5,7 @@
5
- Keeps interface contracts stable enough for specialists to work independently.
6
7
## Learnings
8
+- 2026-06-05T15:36:19.379+00:00 issue #234: for small RSS enrichment sets in the weekly crawl, prefer a config-driven source list plus bounded in-process parallel fetches over separate Actions jobs; runner setup/artifact overhead outweighs per-feed job parallelism.
9
- Branch protection must never be bypassed; automated write paths should follow the shared `branch-protection-pr-workflow` skill instead.
10
- The learning loop only works when agent identity is loaded before execution, outcomes are persisted after execution, and that state is injected into the next run.
11
- Copilot CLI agent selection uses the registered agent name, not the path to the agent file.
.squad/decisions/inbox/bender-pr-236-security-fix.md
new
+10
@@ -0,0 +1,10 @@
1
+# Bender PR #236 Security Fix
2
+
3
+## Context
4
+Hermes blocked PR #236 because config-driven external RSS sources were fetched directly without egress URL validation or explicit per-request timeouts.
5
+
6
+## Decision
7
+External news RSS source configs now require HTTPS URLs whose host is in the approved feed allowlist, with credentials, local/private/link-local targets, and unexpected ports rejected before crawl. Fetching now goes through `urllib.request.urlopen` with an explicit bounded timeout before handing bytes to `feedparser`, while retaining the existing config-driven source list and bounded in-process worker pool.
8
+
9
+## Validation
10
+Added tests for invalid/unapproved URL rejection and explicit fetch timeout propagation. Ran `PYTHONPATH=. .venv/bin/python -m pytest tests -q` with 563 passing tests.
.squad/decisions/inbox/leela-issue-234-sources.md
new
+18
@@ -0,0 +1,18 @@
1
+# Leela — Issue 234 external news source architecture
2
+
3
+Date: 2026-06-05T15:36:19.379+00:00
4
+Issue: #234
5
+
6
+## Decision
7
+
8
+Keep external news crawling in the existing crawl job and make the RSS source list config-driven via `config/external_news_sources.json`. Fetch the configured feeds concurrently inside `scripts/techcrunch_crawler.py` using a bounded thread pool, and write one weekly enrichment artifact: `data/raw/YYYY-WNN-external-news.json`.
9
+
10
+## Rubberduck tradeoff
11
+
12
+Separate GitHub Actions jobs would parallelize at the runner level, but every source would repeat checkout, Python setup, dependency install, artifact upload/download, and failure-handling boilerplate. For five RSS feeds, that overhead is larger than the network wait we are optimizing away, and it would fragment a single enrichment contract across multiple artifacts.
13
+
14
+In-process threading matches the current architecture better: RSS fetching is I/O-bound, feedparser work is light, and the existing crawl job already owns raw data artifact handoff. A bounded pool preserves Actions compute, keeps one failure surface, and lets future sources be added by config without editing workflow topology.
15
+
16
+## Scope boundary
17
+
18
+This is a small architectural refactor around an existing RSS crawler, so Leela implemented directly rather than reassigning to Bender. Deeper crawler work, such as source-specific parsing, feed health dashboards, or correlation logic, should remain Bender-owned.
.squad/skills/ci-data-source-integration-pattern/SKILL.md
+4
@@ -195,6 +195,10 @@ def main(argv: list[str] | None = None) -> int:
195
return 0
196
```
197
198
+### Config-Driven Parallel RSS Sources
199
+
200
+For small sets of external RSS feeds in the weekly Actions pipeline, prefer one config file plus bounded in-process parallel fetches over one job per feed. This avoids repeated checkout/setup/artifact overhead, keeps a single enrichment artifact contract, and lets maintainers add or remove sources without editing workflow topology.
201
+
202
## Notes
203
204
- Standardize output schemas across all data sources for seamless pipeline integration
README.md
+6
-4
@@ -26,10 +26,11 @@ Crawl → Analyze → Generate → Deploy → Reskill
26
JSON Markdown Hugo Pages Improvements
27
```
28
29
-**Stage 1: Crawl** (`scripts/crawl.py`)
29
+**Stage 1: Crawl** (`scripts/crawl.py`, `scripts/techcrunch_crawler.py`)
30
- Queries GitHub API for repos created/trending in the current week
31
+- Fetches configured external RSS feeds from `config/external_news_sources.json` in parallel as an enrichment signal
32
- Applies heuristic filtering (language, topic, description quality)
32
-- Outputs: `data/raw/YYYY-WNN.json`, `data/snapshots/YYYY-WNN-stars.json`
33
+- Outputs: `data/raw/YYYY-WNN.json`, `data/raw/YYYY-WNN-external-news.json`, `data/snapshots/YYYY-WNN-stars.json`
34
35
**Stage 2: Analyze** (Copilot CLI or fallback)
36
- Reads raw JSON; applies AI analysis to classify repos as signal/noise/gaps
@@ -93,7 +94,8 @@ JSON Markdown Hugo Pages Improvements
94
- `content/weekly/YYYY/WNN.md` — immutable weekly summaries (published once, never modified)
95
- `content/monthly/YYYY/MM.md` — monthly rollups (append-only)
96
- `content/yearly/YYYY.md` — yearly summaries (append-only)
96
-- `data/raw/YYYY-WNN.json` — crawler output (JSON object with keys: `week`, `new_repos`, `trending_repos`, `signals`, `metadata`)
97
+- `data/raw/YYYY-WNN.json` — GitHub crawler output (JSON object with keys: `week`, `new_repos`, `trending_repos`, `signals`, `metadata`)
98
+- `data/raw/YYYY-WNN-external-news.json` — external RSS enrichment output from sources configured in `config/external_news_sources.json`
99
- `data/analyzed/YYYY-WNN-summary.md` — AI analysis with quality score
100
- `data/snapshots/YYYY-WNN-stars.json` — star count snapshots for trending analysis
101
@@ -101,7 +103,7 @@ JSON Markdown Hugo Pages Improvements
103
104
`.github/workflows/crawl-and-publish.yml` runs the full weekly automation every Monday at 08:00 UTC:
105
104
-1. **Crawl:** GitHub API → `data/raw/YYYY-WNN.json`
106
+1. **Crawl:** GitHub API → `data/raw/YYYY-WNN.json`; external RSS feeds → `data/raw/YYYY-WNN-external-news.json`
107
2. **Analyze:** Copilot → `data/analyzed/YYYY-WNN-summary.md`
108
3. **Quality gate:** Validates quality_score ≥ 60; blocks publish if failed
109
4. **Generate:** Markdown → `content/weekly/YYYY/WNN.md`
config/external_news_sources.json
new
+27
@@ -0,0 +1,27 @@
1
+[
2
+ {
3
+ "name": "techcrunch",
4
+ "feed_url": "https://techcrunch.com/feed/",
5
+ "requests_per_minute": 10
6
+ },
7
+ {
8
+ "name": "nvidia_blog",
9
+ "feed_url": "https://blogs.nvidia.com/feed/",
10
+ "requests_per_minute": 10
11
+ },
12
+ {
13
+ "name": "hugging_face_blog",
14
+ "feed_url": "https://huggingface.co/blog/feed.xml",
15
+ "requests_per_minute": 10
16
+ },
17
+ {
18
+ "name": "mit_technology_review",
19
+ "feed_url": "https://www.technologyreview.com/feed",
20
+ "requests_per_minute": 10
21
+ },
22
+ {
23
+ "name": "github_blog",
24
+ "feed_url": "https://github.blog/feed/",
25
+ "requests_per_minute": 10
26
+ }
27
+]
docs/pipeline-validation.md
+3
@@ -28,6 +28,7 @@ Required secrets/tokens:
28
29
**Outputs**
30
- `data/raw/YYYY-WNN.json`
31
+- `data/raw/YYYY-WNN-external-news.json`
32
- `data/snapshots/YYYY-WNN-stars.json`
33
- `raw-data` artifact
34
- `crawl-snapshots` artifact
@@ -36,6 +37,7 @@ Required secrets/tokens:
37
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
- Snapshot file is written for the same ISO week
42
- Cache artifact uploads even on partial failures
43
- Job permissions include `actions: read` and `contents: write` at workflow level for cache restore and commits
@@ -118,6 +120,7 @@ Required secrets/tokens:
120
### Trigger locally
121
122
- Crawl: `python3 scripts/crawl.py --as-of YYYY-MM-DD`
123
+- 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`
124
- 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`
125
- 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`
126
- Generate: `python3 scripts/generate_content.py data/analyzed/YYYY-WNN-summary.md`
scripts/analyze_fallback.py
+3
-1
@@ -374,7 +374,9 @@ def _render_press_section_no_ai(press_context_path: Path | None) -> str:
374
stem = press_context_path.stem # e.g. "2026-W21-press-context"
375
week = stem.replace("-press-context", "") # e.g. "2026-W21"
376
data_dir = press_context_path.parent.parent # data/analyzed/ -> data/
377
- tc_path = data_dir / "raw" / f"{week}-techcrunch.json"
377
+ external_path = data_dir / "raw" / f"{week}-external-news.json"
378
+ legacy_path = data_dir / "raw" / f"{week}-techcrunch.json"
379
+ tc_path = external_path if external_path.exists() else legacy_path
380
corr_path = data_dir / "analyzed" / f"{week}-correlations.json"
381
382
if tc_path.exists():
scripts/correlate.py
+7
-5
@@ -1,12 +1,12 @@
1
#!/usr/bin/env python3
2
"""Cross-source correlation engine for SquadScope.
3
4
-Matches TechCrunch articles to GitHub repo activity using fuzzy matching
4
+Matches external news articles to GitHub repo activity using fuzzy matching
5
heuristics to identify press-correlated repositories.
6
7
Usage:
8
python scripts/correlate.py [--raw data/raw/ai-ml/2026-W21.json] \
9
- [--techcrunch data/raw/ai-ml/2026-W21-techcrunch.json] \
9
+ [--techcrunch data/raw/ai-ml/2026-W21-external-news.json] \
10
[--output data/analyzed/ai-ml/2026-W21-correlations.json] \
11
[--topic ai-ml]
12
"""
@@ -384,7 +384,7 @@ def main(argv: list[str] | None = None) -> int:
384
)
385
parser.add_argument(
386
"--techcrunch", default=None,
387
- help="Path to TechCrunch articles JSON file",
387
+ help="Path to external news articles JSON file",
388
)
389
parser.add_argument(
390
"--output", default=None,
@@ -411,11 +411,13 @@ def main(argv: list[str] | None = None) -> int:
411
log(f"Raw file not found: {raw_path}")
412
return 1
413
414
- # Resolve TechCrunch file
414
+ # Resolve external news file, with TechCrunch-only legacy fallback.
415
if args.techcrunch:
416
tc_path = Path(args.techcrunch)
417
else:
418
- tc_path = find_latest_file(raw_dir(topic), "*-techcrunch.json")
418
+ tc_path = find_latest_file(raw_dir(topic), "*-external-news.json")
419
+ if tc_path is None:
420
+ tc_path = find_latest_file(raw_dir(topic), "*-techcrunch.json")
421
422
# Load repos
423
raw_data = load_json(raw_path)
scripts/render_press_context.py
+5
-2
@@ -574,8 +574,11 @@ def render_press_context(
574
575
576
def resolve_paths(topic: str | None, week: str) -> tuple[Path, Path]:
577
- """Resolve file paths for TechCrunch and correlation data."""
578
- tc_path = raw_dir(topic) / f"{week}-techcrunch.json"
577
+ """Resolve file paths for external news and correlation data."""
578
+ raw_path = raw_dir(topic)
579
+ external_path = raw_path / f"{week}-external-news.json"
580
+ legacy_path = raw_path / f"{week}-techcrunch.json"
581
+ tc_path = legacy_path if legacy_path.exists() and not external_path.exists() else external_path
582
corr_path = analyzed_dir(topic) / f"{week}-correlations.json"
583
return tc_path, corr_path
584
scripts/techcrunch_crawler.py
+197
-19
@@ -1,30 +1,46 @@
1
#!/usr/bin/env python3
2
-"""TechCrunch RSS crawler with entity extraction for SquadScope.
2
+"""External news RSS crawler with entity extraction for SquadScope.
3
4
-Fetches articles from TechCrunch RSS feed, extracts structured metadata,
4
+Fetches configured RSS feeds in parallel, extracts structured metadata,
5
GitHub URLs, and entities (company/project names).
6
7
Usage:
8
python scripts/techcrunch_crawler.py [--topic ai-ml] \
9
- [--output data/raw/ai-ml/2026-W21-techcrunch.json] [--since 2026-05-11]
9
+ [--output data/raw/ai-ml/2026-W21-external-news.json] [--since 2026-05-11]
10
"""
11
12
from __future__ import annotations
13
14
import argparse
15
+import ipaddress
16
import json
17
import re
18
import sys
19
import time
20
+from collections import Counter
21
+from concurrent.futures import ThreadPoolExecutor, as_completed
22
+from dataclasses import dataclass
23
from datetime import UTC, datetime, timedelta
24
from pathlib import Path
25
from typing import Any
26
+from urllib.parse import urlparse
27
+from urllib.request import Request, urlopen
28
29
import feedparser
30
31
from scripts.topic_paths import raw_dir
32
33
FEED_URL = "https://techcrunch.com/feed/"
34
+DEFAULT_SOURCES_PATH = Path("config/external_news_sources.json")
35
+DEFAULT_FETCH_TIMEOUT_SECONDS = 15
36
+DEFAULT_MAX_WORKERS = 8
37
+APPROVED_FEED_HOSTS = frozenset({
38
+ "techcrunch.com",
39
+ "blogs.nvidia.com",
40
+ "huggingface.co",
41
+ "www.technologyreview.com",
42
+ "github.blog",
43
+})
44
45
GITHUB_URL_RE = re.compile(
46
r"https?://github\.com/[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+"
@@ -56,6 +72,78 @@ STOP_WORDS = {
72
}
73
74
75
+@dataclass(frozen=True, slots=True)
76
+class NewsSourceConfig:
77
+ """Configuration for one external RSS source."""
78
+
79
+ name: str
80
+ feed_url: str
81
+ requests_per_minute: int = 10
82
+
83
+ def __post_init__(self) -> None:
84
+ validate_feed_url(self.feed_url)
85
+
86
+
87
+def validate_feed_url(url: str) -> None:
88
+ """Validate an external RSS URL against the approved egress allowlist."""
89
+ parsed = urlparse(url)
90
+ if parsed.scheme.lower() != "https":
91
+ raise ValueError(f"External RSS feed URL must use HTTPS: {url}")
92
+ if parsed.username or parsed.password:
93
+ raise ValueError(f"External RSS feed URL must not include credentials: {url}")
94
+ host = (parsed.hostname or "").rstrip(".").lower()
95
+ if not host:
96
+ raise ValueError(f"External RSS feed URL must include a hostname: {url}")
97
+ try:
98
+ port = parsed.port
99
+ except ValueError as exc:
100
+ raise ValueError(f"External RSS feed URL has an invalid port: {url}") from exc
101
+ if port not in (None, 443):
102
+ raise ValueError(f"External RSS feed URL must not use unexpected ports: {url}")
103
+ if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
104
+ raise ValueError(f"External RSS feed URL must not target local hosts: {url}")
105
+ try:
106
+ ip_addr = ipaddress.ip_address(host)
107
+ except ValueError:
108
+ pass
109
+ else:
110
+ if ip_addr.is_private or ip_addr.is_loopback or ip_addr.is_link_local:
111
+ raise ValueError(
112
+ f"External RSS feed URL must not target private/local IPs: {url}"
113
+ )
114
+ if host not in APPROVED_FEED_HOSTS:
115
+ approved = ", ".join(sorted(APPROVED_FEED_HOSTS))
116
+ raise ValueError(
117
+ f"External RSS feed host is not approved: {host} (approved: {approved})"
118
+ )
119
+
120
+
121
+def load_source_configs(path: Path = DEFAULT_SOURCES_PATH) -> list[NewsSourceConfig]:
122
+ """Load external RSS source config from JSON."""
123
+ payload = json.loads(path.read_text(encoding="utf-8"))
124
+ if not isinstance(payload, list):
125
+ raise ValueError(f"Expected a list of source configs in {path}")
126
+
127
+ sources: list[NewsSourceConfig] = []
128
+ seen_names: set[str] = set()
129
+ for raw in payload:
130
+ if not isinstance(raw, dict):
131
+ raise ValueError(f"Invalid source config in {path}: {raw!r}")
132
+ name = str(raw.get("name", "")).strip()
133
+ feed_url = str(raw.get("feed_url", "")).strip()
134
+ if not name or not feed_url:
135
+ raise ValueError(f"Source configs require name and feed_url: {raw!r}")
136
+ if name in seen_names:
137
+ raise ValueError(f"Duplicate external news source name: {name}")
138
+ seen_names.add(name)
139
+ sources.append(NewsSourceConfig(
140
+ name=name,
141
+ feed_url=feed_url,
142
+ requests_per_minute=int(raw.get("requests_per_minute", 10)),
143
+ ))
144
+ return sources
145
+
146
+
147
def iso_timestamp(value: datetime) -> str:
148
"""Format datetime as ISO 8601 UTC string."""
149
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
@@ -138,10 +226,25 @@ def parse_published_date(entry: Any) -> datetime | None:
226
return None
227
228
141
-def fetch_feed(url: str = FEED_URL, retries: int = 1) -> Any:
142
- """Fetch and parse RSS feed with retry on failure."""
229
+def fetch_feed(
230
+ url: str = FEED_URL,
231
+ retries: int = 1,
232
+ timeout: int = DEFAULT_FETCH_TIMEOUT_SECONDS,
233
+) -> Any:
234
+ """Fetch and parse RSS feed with bounded retries and an explicit timeout."""
235
+ validate_feed_url(url)
236
+ if timeout <= 0:
237
+ raise ValueError("RSS fetch timeout must be greater than zero")
238
for attempt in range(retries + 1):
144
- feed = feedparser.parse(url)
239
+ try:
240
+ request = Request(url, headers={"User-Agent": "SquadScope RSS crawler"})
241
+ with urlopen(request, timeout=timeout) as response:
242
+ feed = feedparser.parse(response.read())
243
+ except Exception:
244
+ if attempt < retries:
245
+ time.sleep(2)
246
+ continue
247
+ raise
248
if feed.bozo and not feed.entries:
249
if attempt < retries:
250
time.sleep(2)
@@ -152,23 +255,28 @@ def fetch_feed(url: str = FEED_URL, retries: int = 1) -> Any:
255
return feed # pragma: no cover
256
257
155
-class TechCrunchSource:
156
- """TechCrunch RSS data source following the DataSource protocol."""
258
+class NewsFeedSource:
259
+ """RSS data source following the DataSource protocol."""
260
+
261
+ def __init__(self, config: NewsSourceConfig) -> None:
262
+ self.config = config
263
264
def get_name(self) -> str:
159
- return "techcrunch"
265
+ return self.config.name
266
267
def get_rate_limits(self) -> dict:
162
- return {"requests_per_minute": 10}
268
+ return {"requests_per_minute": self.config.requests_per_minute}
269
270
def crawl(
271
self,
272
since: datetime,
273
until: datetime,
168
- feed_url: str = FEED_URL,
274
+ feed_url: str | None = None,
275
) -> list[dict[str, Any]]:
170
- """Crawl TechCrunch RSS feed and return structured articles."""
171
- feed = fetch_feed(feed_url)
276
+ """Crawl an RSS feed and return structured articles."""
277
+ resolved_feed_url = feed_url or self.config.feed_url
278
+ validate_feed_url(resolved_feed_url)
279
+ feed = fetch_feed(resolved_feed_url)
280
articles: list[dict[str, Any]] = []
281
282
for entry in feed.entries:
@@ -197,6 +305,7 @@ class TechCrunchSource:
305
summary = summary[:497] + "..."
306
307
article: dict[str, Any] = {
308
+ "source": self.get_name(),
309
"title": getattr(entry, "title", ""),
310
"url": getattr(entry, "link", ""),
311
"published_at": iso_timestamp(pub_date),
@@ -211,9 +320,54 @@ class TechCrunchSource:
320
return articles
321
322
323
+class TechCrunchSource(NewsFeedSource):
324
+ """Backward-compatible TechCrunch RSS source."""
325
+
326
+ def __init__(self) -> None:
327
+ super().__init__(NewsSourceConfig("techcrunch", FEED_URL, 10))
328
+
329
+
330
+def crawl_sources_parallel(
331
+ sources: list[NewsSourceConfig],
332
+ since: datetime,
333
+ until: datetime,
334
+ 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."""
337
+ if not sources:
338
+ return [], []
339
+
340
+ if max_workers is not None and max_workers < 1:
341
+ raise ValueError("--max-workers must be at least 1")
342
+ workers = min(max_workers or len(sources), len(sources), DEFAULT_MAX_WORKERS)
343
+ articles: list[dict[str, Any]] = []
344
+ errors: list[dict[str, str]] = []
345
+ with ThreadPoolExecutor(max_workers=workers) as executor:
346
+ futures = {
347
+ executor.submit(NewsFeedSource(source).crawl, since, until): source
348
+ for source in sources
349
+ }
350
+ 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)})
356
+
357
+ articles.sort(
358
+ key=lambda article: (article.get("published_at", ""), article.get("source", "")),
359
+ reverse=True,
360
+ )
361
+ return articles, errors
362
+
363
+
364
def build_output(
365
articles: list[dict[str, Any]],
366
crawled_at: datetime,
367
+ *,
368
+ source: str = "techcrunch",
369
+ source_count: int = 1,
370
+ errors: list[dict[str, str]] | None = None,
371
) -> dict[str, Any]:
372
"""Build the final output structure with metadata."""
373
relevant = [a for a in articles if a["relevance_score"] >= 0.4]
@@ -221,22 +375,26 @@ def build_output(
375
for a in articles:
376
all_github_links.update(a.get("github_links", []))
377
378
+ by_source = Counter(str(article.get("source", source)) for article in articles)
379
return {
380
"week": week_slug(crawled_at),
226
- "source": "techcrunch",
381
+ "source": source,
382
"crawled_at": iso_timestamp(crawled_at),
383
"articles": articles,
384
"metadata": {
385
+ "source_count": source_count,
386
+ "sources_with_articles": dict(sorted(by_source.items())),
387
"total_articles": len(articles),
388
"relevant_articles": len(relevant),
389
"github_links_found": len(all_github_links),
390
+ "errors": errors or [],
391
},
392
}
393
394
395
def main(argv: list[str] | None = None) -> int:
396
parser = argparse.ArgumentParser(
239
- description="Crawl TechCrunch RSS feed for SquadScope"
397
+ description="Crawl external news RSS feeds for SquadScope"
398
)
399
parser.add_argument(
400
"--topic", default="general",
@@ -254,6 +412,17 @@ def main(argv: list[str] | None = None) -> int:
412
"--until", default=None,
413
help="End date filter (YYYY-MM-DD, default: now)",
414
)
415
+ parser.add_argument(
416
+ "--sources",
417
+ default=str(DEFAULT_SOURCES_PATH),
418
+ help="Path to external RSS source config JSON",
419
+ )
420
+ parser.add_argument(
421
+ "--max-workers",
422
+ type=int,
423
+ default=None,
424
+ help="Maximum parallel RSS fetches (default: one per source, capped at 8)",
425
+ )
426
args = parser.parse_args(argv)
427
428
now = datetime.now(UTC)
@@ -268,22 +437,31 @@ def main(argv: list[str] | None = None) -> int:
437
else now
438
)
439
271
- source = TechCrunchSource()
272
- articles = source.crawl(since=since, until=until)
273
- output = build_output(articles, crawled_at=now)
440
+ source_configs = load_source_configs(Path(args.sources))
441
+ articles, errors = crawl_sources_parallel(
442
+ source_configs, since=since, until=until, max_workers=args.max_workers
443
+ )
444
+ output = build_output(
445
+ articles,
446
+ crawled_at=now,
447
+ source="external_news",
448
+ source_count=len(source_configs),
449
+ errors=errors,
450
+ )
451
452
if args.output:
453
out_path = Path(args.output)
454
else:
455
out_dir = raw_dir(args.topic)
456
out_dir.mkdir(parents=True, exist_ok=True)
280
- out_path = out_dir / f"{week_slug(now)}-techcrunch.json"
457
+ out_path = out_dir / f"{week_slug(now)}-external-news.json"
458
459
out_path.parent.mkdir(parents=True, exist_ok=True)
460
with open(out_path, "w", encoding="utf-8") as f:
461
json.dump(output, f, indent=2, ensure_ascii=False)
462
463
print(f"Crawled {output['metadata']['total_articles']} articles "
464
+ f"from {output['metadata']['source_count']} sources "
465
f"({output['metadata']['relevant_articles']} relevant) → {out_path}")
466
return 0
467
tests/test_render_press_context.py
+6
-1
@@ -192,10 +192,15 @@ class TestRenderPressContext:
192
class TestResolvePaths:
193
def test_with_topic(self):
194
tc, corr = resolve_paths("ai-ml", "2026-W21")
195
- assert "raw/ai-ml/2026-W21-techcrunch.json" in str(tc)
195
+ assert "raw/ai-ml/2026-W21-external-news.json" in str(tc)
196
assert "analyzed/ai-ml/2026-W21-correlations.json" in str(corr)
197
198
def test_without_topic(self):
199
+ tc, corr = resolve_paths(None, "2026-W99")
200
+ assert "2026-W99-external-news.json" in str(tc)
201
+ assert "2026-W99-correlations.json" in str(corr)
202
+
203
+ def test_legacy_techcrunch_fallback(self):
204
tc, corr = resolve_paths(None, "2026-W21")
205
assert "2026-W21-techcrunch.json" in str(tc)
206
assert "2026-W21-correlations.json" in str(corr)
tests/test_techcrunch_crawler.py
+148
@@ -2,6 +2,7 @@
2
3
from __future__ import annotations
4
5
+import json
6
from datetime import UTC, datetime, timedelta
7
from types import SimpleNamespace
8
from unittest.mock import patch
@@ -9,13 +10,20 @@ from unittest.mock import patch
10
import pytest
11
12
from scripts.techcrunch_crawler import (
13
+ DEFAULT_SOURCES_PATH,
14
+ DEFAULT_FETCH_TIMEOUT_SECONDS,
15
+ NewsSourceConfig,
16
TechCrunchSource,
17
build_output,
18
compute_relevance_score,
19
+ crawl_sources_parallel,
20
extract_entities,
21
extract_github_urls,
22
+ fetch_feed,
23
iso_timestamp,
24
+ load_source_configs,
25
parse_published_date,
26
+ validate_feed_url,
27
week_slug,
28
)
29
@@ -188,6 +196,7 @@ class TestTechCrunchSourceCrawl:
196
)
197
198
assert len(articles) == 1
199
+ assert articles[0]["source"] == "techcrunch"
200
assert articles[0]["title"] == "Test Article"
201
202
def test_crawl_extracts_github_links(self):
@@ -267,9 +276,11 @@ class TestBuildOutput:
276
assert output["source"] == "techcrunch"
277
assert output["week"] == week_slug(now)
278
assert output["crawled_at"] == "2026-05-19T10:00:00Z"
279
+ assert output["metadata"]["source_count"] == 1
280
assert output["metadata"]["total_articles"] == 2
281
assert output["metadata"]["relevant_articles"] == 1
282
assert output["metadata"]["github_links_found"] == 1
283
+ assert output["metadata"]["errors"] == []
284
assert len(output["articles"]) == 2
285
286
@@ -285,3 +296,140 @@ class TestDataSourceProtocol:
296
limits = source.get_rate_limits()
297
assert "requests_per_minute" in limits
298
assert limits["requests_per_minute"] == 10
299
+
300
+
301
+# --- Config and parallel crawl tests ---
302
+
303
+class TestExternalNewsSources:
304
+ def test_load_default_source_configs(self):
305
+ sources = load_source_configs(DEFAULT_SOURCES_PATH)
306
+ names = {source.name for source in sources}
307
+
308
+ assert "techcrunch" in names
309
+ assert "nvidia_blog" in names
310
+ assert "hugging_face_blog" in names
311
+ assert "mit_technology_review" in names
312
+ assert "github_blog" in names
313
+
314
+ @pytest.mark.parametrize(
315
+ "feed_url",
316
+ [
317
+ "http://techcrunch.com/feed/",
318
+ "https://user:pass@techcrunch.com/feed/",
319
+ "https://localhost/feed/",
320
+ "https://127.0.0.1/feed/",
321
+ "https://169.254.169.254/feed/",
322
+ "https://example.com/feed/",
323
+ "https://techcrunch.com:8443/feed/",
324
+ ],
325
+ )
326
+ def test_rejects_invalid_or_unapproved_feed_urls(self, feed_url):
327
+ with pytest.raises(ValueError):
328
+ validate_feed_url(feed_url)
329
+
330
+ def test_load_source_configs_rejects_unapproved_hosts(self):
331
+ payload = json.dumps([
332
+ {
333
+ "name": "evil",
334
+ "feed_url": "https://example.com/feed.xml",
335
+ "requests_per_minute": 10,
336
+ }
337
+ ])
338
+
339
+ with patch("pathlib.Path.read_text", return_value=payload):
340
+ with pytest.raises(ValueError, match="not approved"):
341
+ load_source_configs(DEFAULT_SOURCES_PATH)
342
+
343
+ def test_fetch_feed_uses_explicit_timeout(self):
344
+ class FakeResponse:
345
+ def __enter__(self):
346
+ return self
347
+
348
+ def __exit__(self, exc_type, exc, traceback):
349
+ return None
350
+
351
+ def read(self):
352
+ return b"<rss><channel></channel></rss>"
353
+
354
+ feed = _make_feed()
355
+ with (
356
+ patch(
357
+ "scripts.techcrunch_crawler.urlopen",
358
+ return_value=FakeResponse(),
359
+ ) as mock_urlopen,
360
+ patch("scripts.techcrunch_crawler.feedparser.parse", return_value=feed),
361
+ ):
362
+ result = fetch_feed("https://techcrunch.com/feed/")
363
+
364
+ assert result is feed
365
+ assert mock_urlopen.call_args.kwargs["timeout"] == DEFAULT_FETCH_TIMEOUT_SECONDS
366
+
367
+ def test_crawl_sources_parallel_combines_sources(self):
368
+ alpha_entry = _make_entry(
369
+ title="Alpha AI framework",
370
+ link="https://example.com/alpha",
371
+ published_parsed=(2026, 5, 16, 10, 0, 0, 4, 136, 0),
372
+ )
373
+ beta_entry = _make_entry(
374
+ title="Beta developer API",
375
+ link="https://example.com/beta",
376
+ published_parsed=(2026, 5, 17, 10, 0, 0, 5, 137, 0),
377
+ )
378
+ feeds = {
379
+ "https://techcrunch.com/feed/": _make_feed(entries=[alpha_entry]),
380
+ "https://github.blog/feed/": _make_feed(entries=[beta_entry]),
381
+ }
382
+
383
+ def fake_fetch(url, retries=1, timeout=DEFAULT_FETCH_TIMEOUT_SECONDS):
384
+ return feeds[url]
385
+
386
+ sources = [
387
+ NewsSourceConfig("alpha", "https://techcrunch.com/feed/"),
388
+ NewsSourceConfig("beta", "https://github.blog/feed/"),
389
+ ]
390
+ with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=fake_fetch):
391
+ articles, errors = crawl_sources_parallel(
392
+ sources,
393
+ since=datetime(2026, 5, 10, tzinfo=UTC),
394
+ until=datetime(2026, 5, 20, tzinfo=UTC),
395
+ max_workers=2,
396
+ )
397
+
398
+ assert errors == []
399
+ assert [article["source"] for article in articles] == ["beta", "alpha"]
400
+ assert {article["title"] for article in articles} == {
401
+ "Alpha AI framework",
402
+ "Beta developer API",
403
+ }
404
+
405
+ def test_external_news_output_metadata(self):
406
+ now = datetime(2026, 5, 19, 10, 0, 0, tzinfo=UTC)
407
+ output = build_output(
408
+ [
409
+ {
410
+ "source": "alpha",
411
+ "title": "AI framework",
412
+ "summary": "open source",
413
+ "categories": [],
414
+ "github_links": [],
415
+ "relevance_score": 0.4,
416
+ },
417
+ {
418
+ "source": "beta",
419
+ "title": "Developer API",
420
+ "summary": "sdk",
421
+ "categories": [],
422
+ "github_links": [],
423
+ "relevance_score": 0.4,
424
+ },
425
+ ],
426
+ crawled_at=now,
427
+ source="external_news",
428
+ source_count=2,
429
+ errors=[{"source": "gamma", "error": "timeout"}],
430
+ )
431
+
432
+ assert output["source"] == "external_news"
433
+ assert output["metadata"]["source_count"] == 2
434
+ assert output["metadata"]["sources_with_articles"] == {"alpha": 1, "beta": 1}
435
+ assert output["metadata"]["errors"] == [{"source": "gamma", "error": "timeout"}]