main
py 1,151 lines 41.9 KB
Raw
1 """Tests for TechCrunch RSS crawler — no live feed fetching."""
2
3 from __future__ import annotations
4
5 import json
6 import tempfile
7 from datetime import UTC, datetime, timedelta, timezone
8 from email.utils import format_datetime
9 from pathlib import Path
10 from types import SimpleNamespace
11 from unittest.mock import patch
12 from urllib.error import HTTPError, URLError
13
14 import pytest
15
16 import scripts.techcrunch_crawler as techcrunch_crawler
17 from scripts.techcrunch_crawler import (
18 DEFAULT_FETCH_RETRIES,
19 DEFAULT_FETCH_TIMEOUT_SECONDS,
20 DEFAULT_SOURCES_PATH,
21 NewsFeedSource,
22 NewsSourceConfig,
23 TechCrunchSource,
24 build_output,
25 compute_relevance_score,
26 crawl_sources_parallel,
27 dedupe_articles,
28 extract_entities,
29 extract_github_urls,
30 fetch_feed,
31 iso_timestamp,
32 load_source_configs,
33 parse_published_date,
34 source_config_checksum,
35 source_reuse_decisions,
36 validate_feed_url,
37 week_slug,
38 )
39
40 # --- Fixtures ---
41
42
43 def _make_entry(
44 title="Test Article",
45 link="https://techcrunch.com/2026/05/15/test/",
46 published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0),
47 summary="A test summary about AI and open-source projects.",
48 content_html="<p>Check out <a href='https://github.com/org/repo'>this repo</a></p>",
49 tags=None,
50 ):
51 entry = SimpleNamespace(
52 title=title,
53 link=link,
54 published_parsed=published_parsed,
55 summary=summary,
56 content=[{"value": content_html}],
57 tags=tags or [SimpleNamespace(term="AI"), SimpleNamespace(term="Startups")],
58 )
59 return entry
60
61
62 def _make_feed(entries=None, bozo=False):
63 return SimpleNamespace(entries=entries or [], bozo=bozo)
64
65
66 def _http_error_with_headers(headers):
67 return HTTPError("https://techcrunch.com/feed/", 503, "Unavailable", headers, None)
68
69
70 # --- Unit tests: utility functions ---
71
72
73 class TestIsoTimestamp:
74 def test_basic(self):
75 dt = datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC)
76 assert iso_timestamp(dt) == "2026-05-15T10:00:00Z"
77
78
79 class TestWeekSlug:
80 def test_basic(self):
81 dt = datetime(2026, 5, 15, tzinfo=UTC)
82 result = week_slug(dt)
83 assert result.startswith("2026-W")
84 assert len(result) == 8
85
86
87 # --- Unit tests: extraction ---
88
89
90 class TestExtractGithubUrls:
91 def test_finds_urls(self):
92 text = "Check https://github.com/langchain-ai/langchain and https://github.com/openai/openai-python"
93 result = extract_github_urls(text)
94 assert "https://github.com/langchain-ai/langchain" in result
95 assert "https://github.com/openai/openai-python" in result
96
97 def test_deduplicates(self):
98 text = "https://github.com/org/repo https://github.com/org/repo"
99 result = extract_github_urls(text)
100 assert len(result) == 1
101
102 def test_empty_input(self):
103 assert extract_github_urls("") == []
104 assert extract_github_urls(None) == []
105
106 def test_no_matches(self):
107 assert extract_github_urls("No GitHub links here") == []
108
109 def test_strips_trailing_punctuation(self):
110 text = "See https://github.com/org/repo."
111 result = extract_github_urls(text)
112 assert result == ["https://github.com/org/repo"]
113
114
115 class TestExtractEntities:
116 def test_finds_proper_nouns(self):
117 title = "OpenAI Launches New GPT Model for Developers"
118 entities = extract_entities(title)
119 assert "OpenAI" in entities
120 assert "GPT" in entities
121 assert "Model" in entities
122 assert "Developers" in entities
123
124 def test_skips_stop_words(self):
125 title = "The New AI Framework"
126 entities = extract_entities(title)
127 # "The" and "New" are stop words
128 assert "The" not in entities
129 assert "New" not in entities
130 assert "AI" in entities
131 assert "Framework" in entities
132
133 def test_empty(self):
134 assert extract_entities("") == []
135 assert extract_entities(None) == []
136
137
138 class TestComputeRelevanceScore:
139 def test_high_relevance(self):
140 article = {
141 "title": "AI startup launches open-source LLM framework",
142 "summary": "A new developer API using machine learning",
143 "categories": ["AI", "Open Source"],
144 "github_links": ["https://github.com/org/repo"],
145 }
146 score = compute_relevance_score(article)
147 assert score >= 0.8
148
149 def test_low_relevance(self):
150 article = {
151 "title": "Company raises funding round",
152 "summary": "The company announced a new funding round today.",
153 "categories": ["Funding"],
154 "github_links": [],
155 }
156 score = compute_relevance_score(article)
157 assert score < 0.4
158
159 def test_github_boost(self):
160 article_no_gh = {
161 "title": "AI tool released",
162 "summary": "",
163 "categories": [],
164 "github_links": [],
165 }
166 article_with_gh = {
167 **article_no_gh,
168 "github_links": ["https://github.com/org/repo"],
169 }
170 score_no = compute_relevance_score(article_no_gh)
171 score_with = compute_relevance_score(article_with_gh)
172 assert score_with > score_no
173
174
175 class TestRetryAfterSeconds:
176 def test_retry_after_numeric_header_preserved_and_floored(self):
177 assert (
178 techcrunch_crawler._retry_after_seconds(
179 _http_error_with_headers({"Retry-After": "120"})
180 )
181 == 120.0
182 )
183 assert (
184 techcrunch_crawler._retry_after_seconds(_http_error_with_headers({"Retry-After": "0"}))
185 == 1.0
186 )
187 assert (
188 techcrunch_crawler._retry_after_seconds(
189 _http_error_with_headers({"Retry-After": "0.5"})
190 )
191 == 1.0
192 )
193
194 def test_retry_after_http_date_future_returns_positive_delay(self):
195 retry_at = datetime.now(timezone.utc) + timedelta(seconds=120)
196 result = techcrunch_crawler._retry_after_seconds(
197 _http_error_with_headers({"Retry-After": format_datetime(retry_at)})
198 )
199
200 assert result is not None
201 assert 60 <= result <= 200
202
203 def test_retry_after_http_date_past_is_floored(self):
204 retry_at = datetime.now(timezone.utc) - timedelta(seconds=120)
205
206 assert (
207 techcrunch_crawler._retry_after_seconds(
208 _http_error_with_headers({"Retry-After": format_datetime(retry_at)})
209 )
210 == 1.0
211 )
212
213 def test_retry_after_garbage_empty_or_missing_returns_none(self):
214 assert (
215 techcrunch_crawler._retry_after_seconds(
216 _http_error_with_headers({"Retry-After": "not-a-date"})
217 )
218 is None
219 )
220 assert (
221 techcrunch_crawler._retry_after_seconds(_http_error_with_headers({"Retry-After": ""}))
222 is None
223 )
224 assert techcrunch_crawler._retry_after_seconds(_http_error_with_headers({})) is None
225
226 def test_retry_after_non_finite_numeric_returns_none(self):
227 for header_value in ("nan", "inf", "-inf", "Infinity"):
228 assert (
229 techcrunch_crawler._retry_after_seconds(
230 _http_error_with_headers({"Retry-After": header_value})
231 )
232 is None
233 )
234
235
236 class TestParsePublishedDate:
237 def test_published_parsed(self):
238 entry = SimpleNamespace(published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0))
239 result = parse_published_date(entry)
240 assert result == datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC)
241
242 def test_fallback_updated(self):
243 entry = SimpleNamespace(
244 published_parsed=None,
245 updated_parsed=(2026, 5, 14, 8, 0, 0, 2, 134, 0),
246 )
247 result = parse_published_date(entry)
248 assert result == datetime(2026, 5, 14, 8, 0, 0, tzinfo=UTC)
249
250 def test_none_when_missing(self):
251 entry = SimpleNamespace(published_parsed=None, updated_parsed=None)
252 assert parse_published_date(entry) is None
253
254
255 # --- Integration tests: TechCrunchSource.crawl ---
256
257
258 class TestTechCrunchSourceCrawl:
259 def test_crawl_filters_by_date(self):
260 entry_in_range = _make_entry(published_parsed=(2026, 5, 15, 10, 0, 0, 3, 135, 0))
261 entry_out_of_range = _make_entry(
262 title="Old Article",
263 published_parsed=(2026, 4, 1, 10, 0, 0, 1, 91, 0),
264 )
265 feed = _make_feed(entries=[entry_in_range, entry_out_of_range])
266
267 with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
268 source = TechCrunchSource()
269 articles = source.crawl(
270 since=datetime(2026, 5, 10, tzinfo=UTC),
271 until=datetime(2026, 5, 20, tzinfo=UTC),
272 )
273
274 assert len(articles) == 1
275 assert articles[0]["source"] == "techcrunch"
276 assert articles[0]["title"] == "Test Article"
277
278 def test_crawl_extracts_github_links(self):
279 entry = _make_entry(
280 content_html="<p>See https://github.com/pytorch/pytorch for details</p>"
281 )
282 feed = _make_feed(entries=[entry])
283
284 with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
285 source = TechCrunchSource()
286 articles = source.crawl(
287 since=datetime(2026, 5, 10, tzinfo=UTC),
288 until=datetime(2026, 5, 20, tzinfo=UTC),
289 )
290
291 assert "https://github.com/pytorch/pytorch" in articles[0]["github_links"]
292
293 def test_crawl_extracts_entities(self):
294 entry = _make_entry(title="OpenAI and LangChain Release New Tools")
295 feed = _make_feed(entries=[entry])
296
297 with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
298 source = TechCrunchSource()
299 articles = source.crawl(
300 since=datetime(2026, 5, 10, tzinfo=UTC),
301 until=datetime(2026, 5, 20, tzinfo=UTC),
302 )
303
304 entities = articles[0]["entities"]
305 assert "OpenAI" in entities
306 assert "LangChain" in entities
307
308 def test_crawl_strips_html_from_summary(self):
309 entry = _make_entry(summary="<p>Hello <b>world</b></p>")
310 feed = _make_feed(entries=[entry])
311
312 with patch("scripts.techcrunch_crawler.fetch_feed", return_value=feed):
313 source = TechCrunchSource()
314 articles = source.crawl(
315 since=datetime(2026, 5, 10, tzinfo=UTC),
316 until=datetime(2026, 5, 20, tzinfo=UTC),
317 )
318
319 assert "<" not in articles[0]["summary"]
320 assert "Hello world" == articles[0]["summary"]
321
322
323 # --- Output structure tests ---
324
325
326 class TestBuildOutput:
327 def test_structure(self):
328 articles = [
329 {
330 "title": "Test",
331 "url": "https://techcrunch.com/test",
332 "published_at": "2026-05-15T10:00:00Z",
333 "categories": ["AI"],
334 "summary": "Test",
335 "github_links": ["https://github.com/org/repo"],
336 "entities": ["OpenAI"],
337 "relevance_score": 0.8,
338 },
339 {
340 "title": "Low relevance",
341 "url": "https://techcrunch.com/low",
342 "published_at": "2026-05-15T11:00:00Z",
343 "categories": [],
344 "summary": "Funding",
345 "github_links": [],
346 "entities": [],
347 "relevance_score": 0.2,
348 },
349 ]
350 now = datetime(2026, 5, 19, 10, 0, 0, tzinfo=UTC)
351 output = build_output(articles, crawled_at=now)
352
353 assert output["source"] == "techcrunch"
354 assert output["week"] == week_slug(now)
355 assert output["crawled_at"] == "2026-05-19T10:00:00Z"
356 assert output["metadata"]["source_count"] == 1
357 assert output["metadata"]["total_articles"] == 2
358 assert output["metadata"]["relevant_articles"] == 1
359 assert output["metadata"]["github_links_found"] == 1
360 assert output["metadata"]["errors"] == []
361 assert len(output["articles"]) == 2
362
363
364 # --- DataSource protocol tests ---
365
366
367 class TestDataSourceProtocol:
368 def test_get_name(self):
369 source = TechCrunchSource()
370 assert source.get_name() == "techcrunch"
371
372 def test_get_rate_limits(self):
373 source = TechCrunchSource()
374 limits = source.get_rate_limits()
375 assert "requests_per_minute" in limits
376 assert limits["requests_per_minute"] == 10
377
378
379 # --- Config and parallel crawl tests ---
380
381
382 class TestExternalNewsSources:
383 def test_load_default_source_configs(self):
384 sources = load_source_configs(DEFAULT_SOURCES_PATH)
385 names = {source.name for source in sources}
386
387 assert "techcrunch" in names
388 assert "nvidia_blog" in names
389 assert "hugging_face_blog" in names
390 assert "mit_technology_review" in names
391 assert "github_blog" in names
392
393 def test_source_reuse_decisions_reuses_successful_same_day_sources_and_refreshes_failed(self):
394 sources = [
395 NewsSourceConfig("techcrunch", "https://techcrunch.com/feed/"),
396 NewsSourceConfig("github-blog", "https://github.blog/feed/"),
397 ]
398 since = datetime(2026, 5, 11, tzinfo=UTC)
399 until = datetime(2026, 5, 18, tzinfo=UTC)
400 payload = {
401 "week": "2026-W21",
402 "crawled_at": "2026-05-18T08:00:00Z",
403 "crawl_window": {"since": iso_timestamp(since), "until": iso_timestamp(until)},
404 "articles": [
405 {"source": "techcrunch", "title": "Reused", "published_at": "2026-05-17T00:00:00Z"}
406 ],
407 "metadata": {
408 "source_config_checksum": source_config_checksum(sources),
409 "source_status": [
410 {"source": "techcrunch", "success": True},
411 {"source": "github-blog", "success": False},
412 ],
413 },
414 }
415
416 reused, to_crawl, reused_statuses, decisions = source_reuse_decisions(
417 payload,
418 sources,
419 week="2026-W21",
420 run_date=datetime(2026, 5, 18, tzinfo=UTC).date(),
421 since=since,
422 until=until,
423 policy="reuse-same-day",
424 current_config_checksum=source_config_checksum(sources),
425 current_code_sha=None,
426 )
427
428 assert [article["title"] for article in reused] == ["Reused"]
429 assert [source.name for source in to_crawl] == ["github-blog"]
430 assert reused_statuses[0]["reused_same_day"] is True
431 assert {decision["source"]: decision["decision"] for decision in decisions} == {
432 "techcrunch": "reuse",
433 "github-blog": "refresh",
434 }
435
436 def test_source_reuse_decisions_refreshes_malformed_article_artifact(self):
437 sources = [NewsSourceConfig("techcrunch", "https://techcrunch.com/feed/")]
438 since = datetime(2026, 5, 11, tzinfo=UTC)
439 until = datetime(2026, 5, 18, tzinfo=UTC)
440 payload = {
441 "week": "2026-W21",
442 "crawled_at": "2026-05-18T08:00:00Z",
443 "crawl_window": {"since": iso_timestamp(since), "until": iso_timestamp(until)},
444 "articles": [{"source": "techcrunch", "sources": 1, "title": "Malformed"}],
445 "metadata": {
446 "source_config_checksum": source_config_checksum(sources),
447 "source_status": [{"source": "techcrunch", "success": True}],
448 },
449 }
450
451 reused, to_crawl, reused_statuses, decisions = source_reuse_decisions(
452 payload,
453 sources,
454 week="2026-W21",
455 run_date=datetime(2026, 5, 18, tzinfo=UTC).date(),
456 since=since,
457 until=until,
458 policy="reuse-same-day",
459 current_config_checksum=source_config_checksum(sources),
460 current_code_sha=None,
461 )
462
463 assert reused == []
464 assert [source.name for source in to_crawl] == ["techcrunch"]
465 assert reused_statuses == []
466 assert decisions == [
467 {
468 "source": "techcrunch",
469 "decision": "refresh",
470 "reasons": ["artifact articles malformed"],
471 }
472 ]
473
474 def test_source_reuse_decisions_refreshes_missing_code_fingerprint_when_required(self):
475 sources = [NewsSourceConfig("techcrunch", "https://techcrunch.com/feed/")]
476 since = datetime(2026, 5, 11, tzinfo=UTC)
477 until = datetime(2026, 5, 18, tzinfo=UTC)
478 payload = {
479 "week": "2026-W21",
480 "crawled_at": "2026-05-18T08:00:00Z",
481 "crawl_window": {"since": iso_timestamp(since), "until": iso_timestamp(until)},
482 "articles": [
483 {"source": "techcrunch", "title": "Reused", "published_at": "2026-05-17T00:00:00Z"}
484 ],
485 "metadata": {
486 "source_config_checksum": source_config_checksum(sources),
487 "source_status": [{"source": "techcrunch", "success": True}],
488 },
489 }
490
491 reused, to_crawl, reused_statuses, decisions = source_reuse_decisions(
492 payload,
493 sources,
494 week="2026-W21",
495 run_date=datetime(2026, 5, 18, tzinfo=UTC).date(),
496 since=since,
497 until=until,
498 policy="reuse-same-day",
499 current_config_checksum=source_config_checksum(sources),
500 current_code_sha="sha",
501 )
502
503 assert reused == []
504 assert [source.name for source in to_crawl] == ["techcrunch"]
505 assert reused_statuses == []
506 assert decisions == [
507 {
508 "source": "techcrunch",
509 "decision": "refresh",
510 "reasons": ["crawler/config fingerprint mismatch"],
511 }
512 ]
513
514 @pytest.mark.parametrize(
515 "feed_url",
516 [
517 "http://techcrunch.com/feed/",
518 "https://user:pass@techcrunch.com/feed/",
519 "https://localhost/feed/",
520 "https://127.0.0.1/feed/",
521 "https://169.254.169.254/feed/",
522 "https://example.com/feed/",
523 "https://techcrunch.com:8443/feed/",
524 ],
525 )
526 def test_rejects_invalid_or_unapproved_feed_urls(self, feed_url):
527 with pytest.raises(ValueError):
528 validate_feed_url(feed_url)
529
530 def test_load_source_configs_rejects_unapproved_hosts(self):
531 payload = json.dumps(
532 [
533 {
534 "name": "evil",
535 "feed_url": "https://example.com/feed.xml",
536 "requests_per_minute": 10,
537 }
538 ]
539 )
540
541 with patch("pathlib.Path.read_text", return_value=payload):
542 with pytest.raises(ValueError, match="not approved"):
543 load_source_configs(DEFAULT_SOURCES_PATH)
544
545 def test_fetch_feed_uses_explicit_timeout(self):
546 class FakeResponse:
547 def __enter__(self):
548 return self
549
550 def __exit__(self, exc_type, exc, traceback):
551 return None
552
553 def read(self):
554 return b"<rss><channel></channel></rss>"
555
556 feed = _make_feed()
557 with (
558 patch(
559 "scripts.techcrunch_crawler.urlopen",
560 return_value=FakeResponse(),
561 ) as mock_urlopen,
562 patch("scripts.techcrunch_crawler.feedparser.parse", return_value=feed),
563 ):
564 result = fetch_feed("https://techcrunch.com/feed/")
565
566 assert result is feed
567 assert mock_urlopen.call_args.kwargs["timeout"] == DEFAULT_FETCH_TIMEOUT_SECONDS
568
569 def test_fetch_feed_retries_transient_error_with_backoff(self):
570 """A transient network error is retried with backoff and can then succeed."""
571 feed = _make_feed()
572
573 class FakeResponse:
574 def __enter__(self):
575 return self
576
577 def __exit__(self, exc_type, exc, traceback):
578 return None
579
580 def read(self):
581 return b"<rss><channel></channel></rss>"
582
583 attempts = {"n": 0}
584
585 def flaky_urlopen(request, timeout=None):
586 attempts["n"] += 1
587 if attempts["n"] == 1:
588 raise URLError("temporary DNS failure")
589 return FakeResponse()
590
591 with (
592 patch("scripts.techcrunch_crawler.urlopen", side_effect=flaky_urlopen),
593 patch("scripts.techcrunch_crawler.feedparser.parse", return_value=feed),
594 patch("scripts.techcrunch_crawler._sleep_before_retry", return_value=0.0) as sleeper,
595 ):
596 result = fetch_feed("https://techcrunch.com/feed/", retries=2)
597
598 assert result is feed
599 assert attempts["n"] == 2
600 assert sleeper.called
601
602 def test_fetch_feed_fails_fast_on_non_retryable_status(self):
603 """Permanent HTTP errors (e.g. 404) must not be retried."""
604 attempts = {"n": 0}
605
606 def not_found(request, timeout=None):
607 attempts["n"] += 1
608 raise HTTPError("https://techcrunch.com/feed/", 404, "Not Found", {}, None)
609
610 with (
611 patch("scripts.techcrunch_crawler.urlopen", side_effect=not_found),
612 patch("scripts.techcrunch_crawler._sleep_before_retry") as sleeper,
613 ):
614 with pytest.raises(HTTPError):
615 fetch_feed("https://techcrunch.com/feed/", retries=3)
616
617 assert attempts["n"] == 1
618 assert not sleeper.called
619
620 def test_fetch_feed_retries_retryable_status(self):
621 """Retryable HTTP statuses (e.g. 503) are retried with backoff."""
622 attempts = {"n": 0}
623
624 def unavailable(request, timeout=None):
625 attempts["n"] += 1
626 raise HTTPError("https://techcrunch.com/feed/", 503, "Unavailable", {}, None)
627
628 with (
629 patch("scripts.techcrunch_crawler.urlopen", side_effect=unavailable),
630 patch("scripts.techcrunch_crawler._sleep_before_retry", return_value=0.0) as sleeper,
631 ):
632 with pytest.raises(HTTPError):
633 fetch_feed("https://techcrunch.com/feed/", retries=2)
634
635 assert attempts["n"] == 3
636 assert sleeper.call_count == 2
637
638 def test_sleep_before_retry_honors_retry_after_120(self):
639 """Server Retry-After of 120s is honored instead of backoff-capped."""
640 with patch("scripts.techcrunch_crawler.time.sleep") as sleep_mock:
641 delay = techcrunch_crawler._sleep_before_retry(0, retry_after=120)
642
643 assert delay == techcrunch_crawler.RETRY_AFTER_MAX_SECONDS
644 sleep_mock.assert_called_once_with(delay)
645
646 def test_sleep_before_retry_bounds_absurd_retry_after(self):
647 """Absurd Retry-After values are capped to the Retry-After maximum."""
648 with patch("scripts.techcrunch_crawler.time.sleep") as sleep_mock:
649 delay = techcrunch_crawler._sleep_before_retry(0, retry_after=99999)
650
651 assert delay == techcrunch_crawler.RETRY_AFTER_MAX_SECONDS
652 sleep_mock.assert_called_once_with(delay)
653
654 def test_sleep_before_retry_honors_small_retry_after(self):
655 """Small positive Retry-After values are honored exactly."""
656 with patch("scripts.techcrunch_crawler.time.sleep") as sleep_mock:
657 delay = techcrunch_crawler._sleep_before_retry(0, retry_after=5)
658
659 assert delay == 5.0
660 sleep_mock.assert_called_once_with(delay)
661
662 def test_sleep_before_retry_without_retry_after_uses_capped_backoff(self):
663 """Computed backoff remains bounded by the backoff maximum."""
664 with patch("scripts.techcrunch_crawler.time.sleep") as sleep_mock:
665 delay = techcrunch_crawler._sleep_before_retry(10, retry_after=None)
666
667 assert 0 < delay <= techcrunch_crawler.RETRY_MAX_DELAY_SECONDS
668 sleep_mock.assert_called_once_with(delay)
669
670 def test_crawl_sources_parallel_combines_sources(self):
671 alpha_entry = _make_entry(
672 title="Alpha AI framework",
673 link="https://example.com/alpha",
674 published_parsed=(2026, 5, 16, 10, 0, 0, 4, 136, 0),
675 )
676 beta_entry = _make_entry(
677 title="Beta developer API",
678 link="https://example.com/beta",
679 published_parsed=(2026, 5, 17, 10, 0, 0, 5, 137, 0),
680 )
681 feeds = {
682 "https://techcrunch.com/feed/": _make_feed(entries=[alpha_entry]),
683 "https://github.blog/feed/": _make_feed(entries=[beta_entry]),
684 }
685
686 def fake_fetch(url, retries=1, timeout=DEFAULT_FETCH_TIMEOUT_SECONDS):
687 return feeds[url]
688
689 sources = [
690 NewsSourceConfig("alpha", "https://techcrunch.com/feed/"),
691 NewsSourceConfig("beta", "https://github.blog/feed/"),
692 ]
693 with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=fake_fetch):
694 articles, errors, statuses = crawl_sources_parallel(
695 sources,
696 since=datetime(2026, 5, 10, tzinfo=UTC),
697 until=datetime(2026, 5, 20, tzinfo=UTC),
698 max_workers=2,
699 )
700
701 assert errors == []
702 assert {status["source"] for status in statuses} == {"alpha", "beta"}
703 assert all(status["success"] for status in statuses)
704 assert [article["source"] for article in articles] == ["beta", "alpha"]
705 assert {article["title"] for article in articles} == {
706 "Alpha AI framework",
707 "Beta developer API",
708 }
709
710 def test_external_news_output_metadata(self):
711 now = datetime(2026, 5, 19, 10, 0, 0, tzinfo=UTC)
712 output = build_output(
713 [
714 {
715 "source": "alpha",
716 "title": "AI framework",
717 "summary": "open source",
718 "categories": [],
719 "github_links": [],
720 "relevance_score": 0.4,
721 },
722 {
723 "source": "beta",
724 "title": "Developer API",
725 "summary": "sdk",
726 "categories": [],
727 "github_links": [],
728 "relevance_score": 0.4,
729 },
730 ],
731 crawled_at=now,
732 source="external_news",
733 source_count=2,
734 crawl_window={
735 "since": "2026-05-12T00:00:00Z",
736 "until": "2026-05-19T00:00:00Z",
737 },
738 source_config_checksum_value="abc123",
739 requested_sources=["alpha", "beta", "gamma"],
740 source_statuses=[
741 {
742 "source": "alpha",
743 "host": "techcrunch.com",
744 "success": True,
745 "attempts": 1,
746 "timeout_seconds": 15,
747 "total_articles": 1,
748 "relevant_articles": 1,
749 "github_links_found": 0,
750 "started_at": "2026-05-19T10:00:00Z",
751 "ended_at": "2026-05-19T10:00:01Z",
752 "duration_seconds": 1.0,
753 "error_class": "",
754 "error_message": "",
755 },
756 {
757 "source": "beta",
758 "host": "github.blog",
759 "success": True,
760 "attempts": 1,
761 "timeout_seconds": 15,
762 "total_articles": 1,
763 "relevant_articles": 1,
764 "github_links_found": 0,
765 "started_at": "2026-05-19T10:00:00Z",
766 "ended_at": "2026-05-19T10:00:01Z",
767 "duration_seconds": 1.0,
768 "error_class": "",
769 "error_message": "",
770 },
771 {
772 "source": "gamma",
773 "host": "example.com",
774 "success": False,
775 "attempts": 2,
776 "timeout_seconds": 15,
777 "total_articles": 0,
778 "relevant_articles": 0,
779 "github_links_found": 0,
780 "started_at": "2026-05-19T10:00:00Z",
781 "ended_at": "2026-05-19T10:00:01Z",
782 "duration_seconds": 1.0,
783 "error_class": "TimeoutError",
784 "error_message": "timeout",
785 },
786 ],
787 errors=[{"source": "gamma", "error": "timeout"}],
788 )
789
790 assert output["schema_version"] == 2
791 assert output["source"] == "external_news"
792 assert output["metadata"]["source_count"] == 2
793 assert output["metadata"]["source_config_checksum"] == "abc123"
794 assert output["metadata"]["sources_requested"] == ["alpha", "beta", "gamma"]
795 assert output["metadata"]["sources_succeeded"] == ["alpha", "beta"]
796 assert output["metadata"]["sources_failed"] == ["gamma"]
797 assert output["metadata"]["artifact_checksum"]
798 assert output["metadata"]["sources_with_articles"] == {"alpha": 1, "beta": 1}
799 assert output["metadata"]["errors"] == [{"source": "gamma", "error": "timeout"}]
800
801 def test_dedupe_articles_preserves_sources(self):
802 articles, deduped = dedupe_articles(
803 [
804 {
805 "source": "alpha",
806 "title": "Same story",
807 "url": "https://example.com/story/",
808 "published_at": "2026-05-15T10:00:00Z",
809 "github_links": ["https://github.com/a/b"],
810 "relevance_score": 0.4,
811 },
812 {
813 "source": "beta",
814 "title": "Same story mirror",
815 "url": "https://example.com/story",
816 "published_at": "2026-05-15T10:00:00Z",
817 "github_links": ["https://github.com/c/d"],
818 "relevance_score": 0.8,
819 },
820 ]
821 )
822
823 assert deduped == 1
824 assert len(articles) == 1
825 assert articles[0]["sources"] == ["alpha", "beta"]
826 assert articles[0]["relevance_score"] == 0.8
827
828 def test_failed_source_reports_bounded_retry_attempts(self):
829 source = NewsSourceConfig("alpha", "https://techcrunch.com/feed/")
830 with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=TimeoutError("boom")):
831 articles, errors, statuses = crawl_sources_parallel(
832 [source],
833 since=datetime(2026, 5, 10, tzinfo=UTC),
834 until=datetime(2026, 5, 20, tzinfo=UTC),
835 max_workers=1,
836 )
837
838 assert articles == []
839 assert errors[0]["error_class"] == "TimeoutError"
840 assert statuses[0]["attempts"] == DEFAULT_FETCH_RETRIES + 1
841 assert statuses[0]["success"] is False
842
843 def test_failed_feed_fetch_records_attempts_on_source(self):
844 source = NewsFeedSource(NewsSourceConfig("alpha", "https://techcrunch.com/feed/"))
845
846 with patch("scripts.techcrunch_crawler.fetch_feed", side_effect=TimeoutError("boom")):
847 with pytest.raises(TimeoutError):
848 source.crawl(
849 since=datetime(2026, 5, 10, tzinfo=UTC),
850 until=datetime(2026, 5, 20, tzinfo=UTC),
851 )
852
853 assert source.last_attempts == DEFAULT_FETCH_RETRIES + 1
854 assert source.last_timeout_seconds == DEFAULT_FETCH_TIMEOUT_SECONDS
855
856
857 class TestSameDaySourceReuse:
858 def _sources(self):
859 return [
860 NewsSourceConfig("alpha", "https://techcrunch.com/feed/"),
861 NewsSourceConfig("beta", "https://github.blog/feed/"),
862 ]
863
864 def _article(self, source, title, url):
865 return {
866 "source": source,
867 "title": title,
868 "url": url,
869 "published_at": "2026-05-19T10:00:00Z",
870 "categories": ["AI"],
871 "summary": "open source AI framework",
872 "github_links": [],
873 "entities": [],
874 "relevance_score": 0.6,
875 }
876
877 def _write_previous(self, path, *, crawled_at, statuses=None, articles=None, sources=None):
878 from scripts.techcrunch_crawler import source_config_checksum
879
880 sources = sources or self._sources()
881 output = build_output(
882 articles or [self._article("alpha", "Alpha", "https://example.com/alpha")],
883 crawled_at=crawled_at,
884 source="external_news",
885 source_count=len(sources),
886 crawl_window={
887 "since": "2026-05-12T00:00:00Z",
888 "until": "2026-05-19T00:00:00Z",
889 },
890 source_config_checksum_value=source_config_checksum(sources),
891 requested_sources=[source.name for source in sources],
892 source_statuses=statuses
893 or [
894 {
895 "source": "alpha",
896 "host": "techcrunch.com",
897 "success": True,
898 "attempts": 1,
899 "timeout_seconds": 15,
900 "total_articles": 1,
901 "relevant_articles": 1,
902 "github_links_found": 0,
903 "started_at": "2026-05-19T08:00:00Z",
904 "ended_at": "2026-05-19T08:00:01Z",
905 "duration_seconds": 1.0,
906 "error_class": "",
907 "error_message": "",
908 },
909 {
910 "source": "beta",
911 "host": "github.blog",
912 "success": True,
913 "attempts": 1,
914 "timeout_seconds": 15,
915 "total_articles": 0,
916 "relevant_articles": 0,
917 "github_links_found": 0,
918 "started_at": "2026-05-19T08:00:00Z",
919 "ended_at": "2026-05-19T08:00:01Z",
920 "duration_seconds": 1.0,
921 "error_class": "",
922 "error_message": "",
923 },
924 ],
925 source_reuse_summary=[],
926 source_artifact_provenance=[],
927 run_id="111",
928 )
929 path.write_text(json.dumps(output), encoding="utf-8")
930
931 def test_reuses_successful_same_day_sources(self, tmp_path):
932 from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
933
934 sources = self._sources()
935 path = tmp_path / "external.json"
936 now = datetime(2026, 5, 19, 9, 0, tzinfo=UTC)
937 self._write_previous(path, crawled_at=now)
938
939 reused, pending, summary, provenance, _ = plan_source_reuse(
940 path,
941 sources,
942 now=now,
943 since=datetime(2026, 5, 12, tzinfo=UTC),
944 until=datetime(2026, 5, 19, tzinfo=UTC),
945 config_checksum=source_config_checksum(sources),
946 )
947
948 assert [item["action"] for item in summary] == ["reused", "reused"]
949 assert pending == []
950 assert [article["title"] for article in reused] == ["Alpha"]
951 assert provenance[0]["original_run_id"] == "111"
952 assert provenance[0]["content_checksum"]
953
954 def test_rejects_yesterday_artifact_as_stale(self, tmp_path):
955 from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
956
957 sources = self._sources()
958 path = tmp_path / "external.json"
959 self._write_previous(path, crawled_at=datetime(2026, 5, 18, 9, 0, tzinfo=UTC))
960
961 reused, pending, summary, _, _ = plan_source_reuse(
962 path,
963 sources,
964 now=datetime(2026, 5, 19, 9, 0, tzinfo=UTC),
965 since=datetime(2026, 5, 12, tzinfo=UTC),
966 until=datetime(2026, 5, 19, tzinfo=UTC),
967 config_checksum=source_config_checksum(sources),
968 )
969
970 assert reused == []
971 assert [source.name for source in pending] == ["alpha", "beta"]
972 assert {item["action"] for item in summary} == {"stale"}
973
974 def test_rejects_missing_code_fingerprint_when_required(self, tmp_path):
975 from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
976
977 sources = self._sources()
978 path = tmp_path / "external.json"
979 now = datetime(2026, 5, 19, 9, 0, tzinfo=UTC)
980 self._write_previous(path, crawled_at=now)
981
982 reused, pending, summary, _, _ = plan_source_reuse(
983 path,
984 sources,
985 now=now,
986 since=datetime(2026, 5, 12, tzinfo=UTC),
987 until=datetime(2026, 5, 19, tzinfo=UTC),
988 config_checksum=source_config_checksum(sources),
989 current_code_sha="sha",
990 )
991
992 assert reused == []
993 assert [source.name for source in pending] == ["alpha", "beta"]
994 assert {item["action"] for item in summary} == {"stale"}
995 assert all("crawler/config fingerprint mismatch" in item["reasons"] for item in summary)
996
997 def test_partial_rerun_reuses_success_and_fetches_failed(self, tmp_path):
998 from scripts.techcrunch_crawler import plan_source_reuse, source_config_checksum
999
1000 sources = self._sources()
1001 path = tmp_path / "external.json"
1002 self._write_previous(
1003 path,
1004 crawled_at=datetime(2026, 5, 19, 9, 0, tzinfo=UTC),
1005 statuses=[
1006 {
1007 "source": "alpha",
1008 "host": "techcrunch.com",
1009 "success": True,
1010 "attempts": 1,
1011 "timeout_seconds": 15,
1012 "total_articles": 1,
1013 "relevant_articles": 1,
1014 "github_links_found": 0,
1015 "started_at": "2026-05-19T08:00:00Z",
1016 "ended_at": "2026-05-19T08:00:01Z",
1017 "duration_seconds": 1.0,
1018 "error_class": "",
1019 "error_message": "",
1020 },
1021 {
1022 "source": "beta",
1023 "host": "github.blog",
1024 "success": False,
1025 "attempts": 2,
1026 "timeout_seconds": 15,
1027 "total_articles": 0,
1028 "relevant_articles": 0,
1029 "github_links_found": 0,
1030 "started_at": "2026-05-19T08:00:00Z",
1031 "ended_at": "2026-05-19T08:00:01Z",
1032 "duration_seconds": 1.0,
1033 "error_class": "TimeoutError",
1034 "error_message": "timeout",
1035 },
1036 ],
1037 )
1038
1039 reused, pending, summary, _, _ = plan_source_reuse(
1040 path,
1041 sources,
1042 now=datetime(2026, 5, 19, 10, 0, tzinfo=UTC),
1043 since=datetime(2026, 5, 12, tzinfo=UTC),
1044 until=datetime(2026, 5, 19, tzinfo=UTC),
1045 config_checksum=source_config_checksum(sources),
1046 )
1047
1048 assert [article["source"] for article in reused] == ["alpha"]
1049 assert [source.name for source in pending] == ["beta"]
1050 assert {item["source"]: item["action"] for item in summary} == {
1051 "alpha": "reused",
1052 "beta": "failed",
1053 }
1054
1055 def test_deterministic_fan_in_dedupes_reused_and_refreshed_articles(self):
1056 first = self._article("alpha", "Same", "https://example.com/story/")
1057 second = self._article("beta", "Same mirror", "https://example.com/story")
1058 deduped_once, count_once = dedupe_articles([first, second])
1059 deduped_twice, count_twice = dedupe_articles([second, first])
1060
1061 assert count_once == count_twice == 1
1062 assert deduped_once == deduped_twice
1063 assert deduped_once[0]["sources"] == ["alpha", "beta"]
1064
1065
1066 def test_main_emits_observability_ledger() -> None:
1067 tests_root = Path(__file__).resolve().parent
1068 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1069 base = Path(tmpdir)
1070 sources_path = base / "sources.json"
1071 output_path = base / "2026-W21-external-news.json"
1072 sources_path.write_text(
1073 json.dumps(
1074 [
1075 {"name": "alpha", "feed_url": "https://techcrunch.com/feed/"},
1076 {"name": "beta", "feed_url": "https://github.blog/feed/"},
1077 ]
1078 ),
1079 encoding="utf-8",
1080 )
1081 statuses = [
1082 {
1083 "source": "alpha",
1084 "host": "techcrunch.com",
1085 "success": True,
1086 "attempts": 1,
1087 "timeout_seconds": 15,
1088 "total_articles": 1,
1089 "relevant_articles": 1,
1090 "github_links_found": 1,
1091 "started_at": "2026-05-19T08:00:00Z",
1092 "ended_at": "2026-05-19T08:00:01Z",
1093 "duration_seconds": 1.0,
1094 "error_class": "",
1095 "error_message": "",
1096 },
1097 {
1098 "source": "beta",
1099 "host": "github.blog",
1100 "success": True,
1101 "attempts": 2,
1102 "timeout_seconds": 15,
1103 "total_articles": 1,
1104 "relevant_articles": 1,
1105 "github_links_found": 0,
1106 "started_at": "2026-05-19T08:00:00Z",
1107 "ended_at": "2026-05-19T08:00:02Z",
1108 "duration_seconds": 2.0,
1109 "error_class": "",
1110 "error_message": "",
1111 },
1112 ]
1113 articles = [
1114 {
1115 "source": "alpha",
1116 "title": "Alpha",
1117 "url": "https://example.com/alpha",
1118 "published_at": "2026-05-19T10:00:00Z",
1119 "categories": ["AI"],
1120 "summary": "alpha summary",
1121 "github_links": ["https://github.com/octo/alpha"],
1122 "entities": ["Alpha"],
1123 "relevance_score": 0.8,
1124 }
1125 ]
1126 with (
1127 patch.object(
1128 techcrunch_crawler, "crawl_sources_parallel", return_value=(articles, [], statuses)
1129 ),
1130 patch.object(techcrunch_crawler, "emit_ledger") as emit_mock,
1131 patch.object(techcrunch_crawler, "print"),
1132 ):
1133 rc = techcrunch_crawler.main(
1134 [
1135 "--sources",
1136 sources_path.as_posix(),
1137 "--output",
1138 output_path.as_posix(),
1139 "--since",
1140 "2026-05-12",
1141 "--until",
1142 "2026-05-19",
1143 ]
1144 )
1145
1146 assert rc == 0
1147 ledger = emit_mock.call_args.args[0]
1148 assert ledger.schema_version == "observability_v1"
1149 assert ledger.crawl_metrics[0].source_type == "external-news"
1150 assert ledger.crawl_metrics[0].api_calls == 3
1151 assert ledger.crawl_metrics[0].duration_p95_seconds == 2.0