feat: TechCrunch RSS crawler with entity extraction (#74, #75) (#107)

- Add feedparser dependency to requirements.txt - Create scripts/techcrunch_crawler.py with TechCrunchSource class - Implement RSS feed fetching with retry, date filtering - Extract GitHub URLs via regex from article content - Extract entities (company/project names) from titles - Compute relevance scores based on tech keyword density - Output structured JSON with metadata to data/raw/{topic}/ - Add comprehensive test suite (23 tests, all mocked) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 19, 2026 at 16:12 UTC 5e5149899854b1bfb6eb083ffbb94a6816dbe02f
3 files changed +580
requirements.txt
+1
@@ -1,3 +1,4 @@
1 # Core dependencies
2 pydantic>=2.0,<3.0
3 pyyaml>=6.0,<7.0
4 +feedparser>=6.0,<7.0
scripts/techcrunch_crawler.py new
+292
@@ -0,0 +1,292 @@
1 +#!/usr/bin/env python3
2 +"""TechCrunch RSS crawler with entity extraction for SquadScope.
3 +
4 +Fetches articles from TechCrunch RSS feed, 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]
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +import argparse
15 +import json
16 +import re
17 +import sys
18 +import time
19 +from datetime import UTC, datetime, timedelta
20 +from pathlib import Path
21 +from typing import Any
22 +
23 +import feedparser
24 +
25 +from scripts.topic_paths import raw_dir
26 +
27 +FEED_URL = "https://techcrunch.com/feed/"
28 +
29 +GITHUB_URL_RE = re.compile(
30 + r"https?://github\.com/[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+"
31 +)
32 +
33 +TECH_KEYWORDS = {
34 + "ai", "ml", "machine learning", "deep learning", "open-source",
35 + "open source", "github", "developer", "api", "framework", "sdk",
36 + "llm", "gpt", "model", "neural", "transformer", "cloud", "devops",
37 + "kubernetes", "docker", "rust", "python", "javascript", "typescript",
38 + "golang", "database", "vector", "embedding", "agent", "rag",
39 + "fine-tuning", "inference", "startup", "oss",
40 +}
41 +
42 +# Common lowercase words that should not be treated as entities
43 +STOP_WORDS = {
44 + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
45 + "of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
46 + "has", "have", "had", "do", "does", "did", "will", "would", "could",
47 + "should", "may", "might", "can", "this", "that", "these", "those",
48 + "it", "its", "new", "how", "why", "what", "when", "where", "who",
49 + "all", "just", "more", "most", "some", "any", "no", "not", "than",
50 + "too", "very", "also", "about", "up", "out", "into", "over", "after",
51 + "before", "between", "under", "again", "here", "there", "now", "then",
52 + "once", "well", "back", "still", "even", "big", "first", "last",
53 + "next", "says", "said", "gets", "got", "makes", "made", "takes",
54 + "took", "goes", "went", "comes", "came", "wants", "launches",
55 + "raises", "builds", "looks", "like", "use", "using", "used",
56 +}
57 +
58 +
59 +def iso_timestamp(value: datetime) -> str:
60 + """Format datetime as ISO 8601 UTC string."""
61 + return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
62 +
63 +
64 +def week_slug(value: datetime) -> str:
65 + """Return ISO week string like '2026-W21'."""
66 + year, week, _ = value.isocalendar()
67 + return f"{year}-W{week:02d}"
68 +
69 +
70 +def extract_github_urls(text: str) -> list[str]:
71 + """Extract unique GitHub repository URLs from text."""
72 + if not text:
73 + return []
74 + urls = GITHUB_URL_RE.findall(text)
75 + # Deduplicate while preserving order
76 + seen: set[str] = set()
77 + result: list[str] = []
78 + for url in urls:
79 + # Strip trailing periods/commas that may be captured
80 + url = url.rstrip(".,;)")
81 + if url not in seen:
82 + seen.add(url)
83 + result.append(url)
84 + return result
85 +
86 +
87 +def extract_entities(title: str) -> list[str]:
88 + """Extract likely entity names (companies, projects) from a title.
89 +
90 + Heuristic: capitalized words that aren't common English words.
91 + """
92 + if not title:
93 + return []
94 + words = re.findall(r"\b[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z]+)*\b", title)
95 + entities: list[str] = []
96 + seen: set[str] = set()
97 + for word in words:
98 + lower = word.lower()
99 + if lower in STOP_WORDS:
100 + continue
101 + if len(word) < 2:
102 + continue
103 + if word not in seen:
104 + seen.add(word)
105 + entities.append(word)
106 + return entities
107 +
108 +
109 +def compute_relevance_score(article: dict[str, Any]) -> float:
110 + """Compute a 0-1 relevance score based on tech/OSS keyword density."""
111 + text = " ".join([
112 + article.get("title", ""),
113 + article.get("summary", ""),
114 + " ".join(article.get("categories", [])),
115 + ]).lower()
116 +
117 + if not text.strip():
118 + return 0.0
119 +
120 + matches = sum(1 for kw in TECH_KEYWORDS if kw in text)
121 + # Normalize: cap at 1.0, scale so 5+ keywords = 1.0
122 + score = min(matches / 5.0, 1.0)
123 + # Boost if GitHub links found
124 + if article.get("github_links"):
125 + score = min(score + 0.2, 1.0)
126 + return round(score, 2)
127 +
128 +
129 +def parse_published_date(entry: Any) -> datetime | None:
130 + """Parse the published date from a feedparser entry."""
131 + published_parsed = getattr(entry, "published_parsed", None)
132 + if published_parsed:
133 + return datetime(*published_parsed[:6], tzinfo=UTC)
134 + # Fallback: try updated_parsed
135 + updated_parsed = getattr(entry, "updated_parsed", None)
136 + if updated_parsed:
137 + return datetime(*updated_parsed[:6], tzinfo=UTC)
138 + return None
139 +
140 +
141 +def fetch_feed(url: str = FEED_URL, retries: int = 1) -> Any:
142 + """Fetch and parse RSS feed with retry on failure."""
143 + for attempt in range(retries + 1):
144 + feed = feedparser.parse(url)
145 + if feed.bozo and not feed.entries:
146 + if attempt < retries:
147 + time.sleep(2)
148 + continue
149 + # Return partial result even on failure
150 + return feed
151 + return feed
152 + return feed # pragma: no cover
153 +
154 +
155 +class TechCrunchSource:
156 + """TechCrunch RSS data source following the DataSource protocol."""
157 +
158 + def get_name(self) -> str:
159 + return "techcrunch"
160 +
161 + def get_rate_limits(self) -> dict:
162 + return {"requests_per_minute": 10}
163 +
164 + def crawl(
165 + self,
166 + since: datetime,
167 + until: datetime,
168 + feed_url: str = FEED_URL,
169 + ) -> list[dict[str, Any]]:
170 + """Crawl TechCrunch RSS feed and return structured articles."""
171 + feed = fetch_feed(feed_url)
172 + articles: list[dict[str, Any]] = []
173 +
174 + for entry in feed.entries:
175 + pub_date = parse_published_date(entry)
176 + if pub_date is None:
177 + continue
178 + if pub_date < since or pub_date >= until:
179 + continue
180 +
181 + # Get content for GitHub URL extraction
182 + content_text = ""
183 + if hasattr(entry, "content") and entry.content:
184 + content_text = entry.content[0].get("value", "")
185 + elif hasattr(entry, "summary"):
186 + content_text = entry.summary or ""
187 +
188 + categories = [
189 + tag.term for tag in getattr(entry, "tags", [])
190 + if hasattr(tag, "term")
191 + ]
192 +
193 + summary = getattr(entry, "summary", "") or ""
194 + # Strip HTML tags from summary
195 + summary = re.sub(r"<[^>]+>", "", summary).strip()
196 + if len(summary) > 500:
197 + summary = summary[:497] + "..."
198 +
199 + article: dict[str, Any] = {
200 + "title": getattr(entry, "title", ""),
201 + "url": getattr(entry, "link", ""),
202 + "published_at": iso_timestamp(pub_date),
203 + "categories": categories,
204 + "summary": summary,
205 + "github_links": extract_github_urls(content_text),
206 + "entities": extract_entities(getattr(entry, "title", "")),
207 + }
208 + article["relevance_score"] = compute_relevance_score(article)
209 + articles.append(article)
210 +
211 + return articles
212 +
213 +
214 +def build_output(
215 + articles: list[dict[str, Any]],
216 + crawled_at: datetime,
217 +) -> dict[str, Any]:
218 + """Build the final output structure with metadata."""
219 + relevant = [a for a in articles if a["relevance_score"] >= 0.4]
220 + all_github_links = set()
221 + for a in articles:
222 + all_github_links.update(a.get("github_links", []))
223 +
224 + return {
225 + "week": week_slug(crawled_at),
226 + "source": "techcrunch",
227 + "crawled_at": iso_timestamp(crawled_at),
228 + "articles": articles,
229 + "metadata": {
230 + "total_articles": len(articles),
231 + "relevant_articles": len(relevant),
232 + "github_links_found": len(all_github_links),
233 + },
234 + }
235 +
236 +
237 +def main(argv: list[str] | None = None) -> int:
238 + parser = argparse.ArgumentParser(
239 + description="Crawl TechCrunch RSS feed for SquadScope"
240 + )
241 + parser.add_argument(
242 + "--topic", default="general",
243 + help="Topic ID for output path (default: general)",
244 + )
245 + parser.add_argument(
246 + "--output", default=None,
247 + help="Override output file path",
248 + )
249 + parser.add_argument(
250 + "--since", default=None,
251 + help="Start date filter (YYYY-MM-DD, default: 7 days ago)",
252 + )
253 + parser.add_argument(
254 + "--until", default=None,
255 + help="End date filter (YYYY-MM-DD, default: now)",
256 + )
257 + args = parser.parse_args(argv)
258 +
259 + now = datetime.now(UTC)
260 + since = (
261 + datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=UTC)
262 + if args.since
263 + else now - timedelta(days=7)
264 + )
265 + until = (
266 + datetime.strptime(args.until, "%Y-%m-%d").replace(tzinfo=UTC)
267 + if args.until
268 + else now
269 + )
270 +
271 + source = TechCrunchSource()
272 + articles = source.crawl(since=since, until=until)
273 + output = build_output(articles, crawled_at=now)
274 +
275 + if args.output:
276 + out_path = Path(args.output)
277 + else:
278 + out_dir = raw_dir(args.topic)
279 + out_dir.mkdir(parents=True, exist_ok=True)
280 + out_path = out_dir / f"{week_slug(now)}-techcrunch.json"
281 +
282 + out_path.parent.mkdir(parents=True, exist_ok=True)
283 + with open(out_path, "w", encoding="utf-8") as f:
284 + json.dump(output, f, indent=2, ensure_ascii=False)
285 +
286 + print(f"Crawled {output['metadata']['total_articles']} articles "
287 + f"({output['metadata']['relevant_articles']} relevant) → {out_path}")
288 + return 0
289 +
290 +
291 +if __name__ == "__main__":
292 + sys.exit(main())
tests/test_techcrunch_crawler.py new
+287
@@ -0,0 +1,287 @@
1 +"""Tests for TechCrunch RSS crawler — no live feed fetching."""
2 +
3 +from __future__ import annotations
4 +
5 +from datetime import UTC, datetime, timedelta
6 +from types import SimpleNamespace
7 +from unittest.mock import patch
8 +
9 +import pytest
10 +
11 +from scripts.techcrunch_crawler import (
12 + TechCrunchSource,
13 + build_output,
14 + compute_relevance_score,
15 + extract_entities,
16 + extract_github_urls,
17 + iso_timestamp,
18 + parse_published_date,
19 + week_slug,
20 +)
21 +
22 +
23 +# --- Fixtures ---
24 +
25 +def _make_entry(
26 + title="Test Article",
27 + link="https://techcrunch.com/2026/05/15/test/",
28 + published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0),
29 + summary="A test summary about AI and open-source projects.",
30 + content_html="<p>Check out <a href='https://github.com/org/repo'>this repo</a></p>",
31 + tags=None,
32 +):
33 + entry = SimpleNamespace(
34 + title=title,
35 + link=link,
36 + published_parsed=published_parsed,
37 + summary=summary,
38 + content=[{"value": content_html}],
39 + tags=tags or [SimpleNamespace(term="AI"), SimpleNamespace(term="Startups")],
40 + )
41 + return entry
42 +
43 +
44 +def _make_feed(entries=None, bozo=False):
45 + return SimpleNamespace(entries=entries or [], bozo=bozo)
46 +
47 +
48 +# --- Unit tests: utility functions ---
49 +
50 +class TestIsoTimestamp:
51 + def test_basic(self):
52 + dt = datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC)
53 + assert iso_timestamp(dt) == "2026-05-15T10:00:00Z"
54 +
55 +
56 +class TestWeekSlug:
57 + def test_basic(self):
58 + dt = datetime(2026, 5, 15, tzinfo=UTC)
59 + result = week_slug(dt)
60 + assert result.startswith("2026-W")
61 + assert len(result) == 8
62 +
63 +
64 +# --- Unit tests: extraction ---
65 +
66 +class TestExtractGithubUrls:
67 + def test_finds_urls(self):
68 + text = "Check https://github.com/langchain-ai/langchain and https://github.com/openai/openai-python"
69 + result = extract_github_urls(text)
70 + assert "https://github.com/langchain-ai/langchain" in result
71 + assert "https://github.com/openai/openai-python" in result
72 +
73 + def test_deduplicates(self):
74 + text = "https://github.com/org/repo https://github.com/org/repo"
75 + result = extract_github_urls(text)
76 + assert len(result) == 1
77 +
78 + def test_empty_input(self):
79 + assert extract_github_urls("") == []
80 + assert extract_github_urls(None) == []
81 +
82 + def test_no_matches(self):
83 + assert extract_github_urls("No GitHub links here") == []
84 +
85 + def test_strips_trailing_punctuation(self):
86 + text = "See https://github.com/org/repo."
87 + result = extract_github_urls(text)
88 + assert result == ["https://github.com/org/repo"]
89 +
90 +
91 +class TestExtractEntities:
92 + def test_finds_proper_nouns(self):
93 + title = "OpenAI Launches New GPT Model for Developers"
94 + entities = extract_entities(title)
95 + assert "OpenAI" in entities
96 + assert "GPT" in entities
97 + assert "Model" in entities
98 + assert "Developers" in entities
99 +
100 + def test_skips_stop_words(self):
101 + title = "The New AI Framework"
102 + entities = extract_entities(title)
103 + # "The" and "New" are stop words
104 + assert "The" not in entities
105 + assert "New" not in entities
106 + assert "AI" in entities
107 + assert "Framework" in entities
108 +
109 + def test_empty(self):
110 + assert extract_entities("") == []
111 + assert extract_entities(None) == []
112 +
113 +
114 +class TestComputeRelevanceScore:
115 + def test_high_relevance(self):
116 + article = {
117 + "title": "AI startup launches open-source LLM framework",
118 + "summary": "A new developer API using machine learning",
119 + "categories": ["AI", "Open Source"],
120 + "github_links": ["https://github.com/org/repo"],
121 + }
122 + score = compute_relevance_score(article)
123 + assert score >= 0.8
124 +
125 + def test_low_relevance(self):
126 + article = {
127 + "title": "Company raises funding round",
128 + "summary": "The company announced a new funding round today.",
129 + "categories": ["Funding"],
130 + "github_links": [],
131 + }
132 + score = compute_relevance_score(article)
133 + assert score < 0.4
134 +
135 + def test_github_boost(self):
136 + article_no_gh = {
137 + "title": "AI tool released",
138 + "summary": "",
139 + "categories": [],
140 + "github_links": [],
141 + }
142 + article_with_gh = {
143 + **article_no_gh,
144 + "github_links": ["https://github.com/org/repo"],
145 + }
146 + score_no = compute_relevance_score(article_no_gh)
147 + score_with = compute_relevance_score(article_with_gh)
148 + assert score_with > score_no
149 +
150 +
151 +class TestParsePublishedDate:
152 + def test_published_parsed(self):
153 + entry = SimpleNamespace(published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0))
154 + result = parse_published_date(entry)
155 + assert result == datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC)
156 +
157 + def test_fallback_updated(self):
158 + entry = SimpleNamespace(
159 + published_parsed=None,
160 + updated_parsed=(2026, 5, 14, 8, 0, 0, 2, 134, 0),
161 + )
162 + result = parse_published_date(entry)
163 + assert result == datetime(2026, 5, 14, 8, 0, 0, tzinfo=UTC)
164 +
165 + def test_none_when_missing(self):
166 + entry = SimpleNamespace(published_parsed=None, updated_parsed=None)
167 + assert parse_published_date(entry) is None
168 +
169 +
170 +# --- Integration tests: TechCrunchSource.crawl ---
171 +
172 +class TestTechCrunchSourceCrawl:
173 + def test_crawl_filters_by_date(self):
174 + entry_in_range = _make_entry(
175 + published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0)
176 + )
177 + entry_out_of_range = _make_entry(
178 + title="Old Article",
179 + published_parsed=(2026, 4, 1, 10, 0, 0, 1, 91, 0),
180 + )
181 + feed = _make_feed(entries=[entry_in_range, entry_out_of_range])
182 +
183 + with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
184 + source = TechCrunchSource()
185 + articles = source.crawl(
186 + since=datetime(2026, 5, 10, tzinfo=UTC),
187 + until=datetime(2026, 5, 20, tzinfo=UTC),
188 + )
189 +
190 + assert len(articles) == 1
191 + assert articles[0]["title"] == "Test Article"
192 +
193 + def test_crawl_extracts_github_links(self):
194 + entry = _make_entry(
195 + content_html="<p>See https://github.com/pytorch/pytorch for details</p>"
196 + )
197 + feed = _make_feed(entries=[entry])
198 +
199 + with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
200 + source = TechCrunchSource()
201 + articles = source.crawl(
202 + since=datetime(2026, 5, 10, tzinfo=UTC),
203 + until=datetime(2026, 5, 20, tzinfo=UTC),
204 + )
205 +
206 + assert "https://github.com/pytorch/pytorch" in articles[0]["github_links"]
207 +
208 + def test_crawl_extracts_entities(self):
209 + entry = _make_entry(title="OpenAI and LangChain Release New Tools")
210 + feed = _make_feed(entries=[entry])
211 +
212 + with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
213 + source = TechCrunchSource()
214 + articles = source.crawl(
215 + since=datetime(2026, 5, 10, tzinfo=UTC),
216 + until=datetime(2026, 5, 20, tzinfo=UTC),
217 + )
218 +
219 + entities = articles[0]["entities"]
220 + assert "OpenAI" in entities
221 + assert "LangChain" in entities
222 +
223 + def test_crawl_strips_html_from_summary(self):
224 + entry = _make_entry(summary="<p>Hello <b>world</b></p>")
225 + feed = _make_feed(entries=[entry])
226 +
227 + with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
228 + source = TechCrunchSource()
229 + articles = source.crawl(
230 + since=datetime(2026, 5, 10, tzinfo=UTC),
231 + until=datetime(2026, 5, 20, tzinfo=UTC),
232 + )
233 +
234 + assert "<" not in articles[0]["summary"]
235 + assert "Hello world" == articles[0]["summary"]
236 +
237 +
238 +# --- Output structure tests ---
239 +
240 +class TestBuildOutput:
241 + def test_structure(self):
242 + articles = [
243 + {
244 + "title": "Test",
245 + "url": "https://techcrunch.com/test",
246 + "published_at": "2026-05-15T10:00:00Z",
247 + "categories": ["AI"],
248 + "summary": "Test",
249 + "github_links": ["https://github.com/org/repo"],
250 + "entities": ["OpenAI"],
251 + "relevance_score": 0.8,
252 + },
253 + {
254 + "title": "Low relevance",
255 + "url": "https://techcrunch.com/low",
256 + "published_at": "2026-05-15T11:00:00Z",
257 + "categories": [],
258 + "summary": "Funding",
259 + "github_links": [],
260 + "entities": [],
261 + "relevance_score": 0.2,
262 + },
263 + ]
264 + now = datetime(2026, 5, 19, 10, 0, 0, tzinfo=UTC)
265 + output = build_output(articles, crawled_at=now)
266 +
267 + assert output["source"] == "techcrunch"
268 + assert output["week"] == week_slug(now)
269 + assert output["crawled_at"] == "2026-05-19T10:00:00Z"
270 + assert output["metadata"]["total_articles"] == 2
271 + assert output["metadata"]["relevant_articles"] == 1
272 + assert output["metadata"]["github_links_found"] == 1
273 + assert len(output["articles"]) == 2
274 +
275 +
276 +# --- DataSource protocol tests ---
277 +
278 +class TestDataSourceProtocol:
279 + def test_get_name(self):
280 + source = TechCrunchSource()
281 + assert source.get_name() == "techcrunch"
282 +
283 + def test_get_rate_limits(self):
284 + source = TechCrunchSource()
285 + limits = source.get_rate_limits()
286 + assert "requests_per_minute" in limits
287 + assert limits["requests_per_minute"] == 10