diff --git a/docs/design.md b/docs/design.md index cff594c..5741283 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1476,10 +1476,11 @@ boto3 위에 얹은, 얇고 로깅이 잘 된 래퍼들입니다. 우아한 성 (게이트 → 필터 → 라인업 선정 → 요약). 글 목록, 날짜 suffix, 제외 목록, 그리고 `CrawlReport`를 돌려줍니다. 4. `_log_run_summary`가 실행 퍼널을 JSON 한 줄(`RUN_SUMMARY {...}`)로 남깁니다. - 소스 ok/empty/failed 수, 수집 수, 탈락 수, 최종 라인업(소스·점수·제목). 수백 줄의 산문 + 소스 ok/empty/failed 수와 글 페이지를 못 읽은 소스 수, 수집 수, 탈락 수, 최종 라인업(소스·점수·제목). 수백 줄의 산문 로그를 읽어 퍼널을 재구성하는 대신 CloudWatch Logs Insights로 바로 질의·추세화할 수 있습니다. -5. 소스가 실패했거나 후보를 내지 못했다면 크롤 헬스 알림을 발행합니다(§7). +5. 소스가 실패했거나, 후보를 내지 못했거나, 글 페이지를 하나도 읽지 못했다면 크롤 헬스 알림을 + 발행합니다(§7). 6. 살아남은 글이 하나도 없으면 200으로 종료합니다. 수집은 됐는데 전량 탈락했다면 그 전에 빈 다이제스트 알림을 발행합니다. 호가 비지 않았어도 일부 글을 모델 호출 실패로 잃었다면 모델 실패 알림을 보냅니다. @@ -1515,7 +1516,7 @@ boto3 위에 얹은, 얇고 로깅이 잘 된 래퍼들입니다. 우아한 성 | 이벤트 | 함수 | 언제 | 왜 따로 알리는가 | | --- | --- | --- | --- | -| `Crawl Health` — ALERT | `_send_crawl_health_alert` | 예상 밖 `FAILED` 소스나 후보 0개 소스가 있을 때 | 깨진 소스는 다이제스트에서 조용히 빠질 뿐입니다(§7) | +| `Crawl Health` — ALERT | `_send_crawl_health_alert` | 예상 밖 `FAILED` 소스, 후보 0개 소스, 글 페이지를 전부 못 읽은 소스가 있을 때 | 깨진 소스는 다이제스트에서 조용히 빠질 뿐입니다(§7) | | `Empty Digest` — ALERT | `_maybe_send_empty_digest_alert` | 수집은 됐는데 살아남은 글이 0편일 때 | 실행은 200으로 끝나고 메일만 안 나갑니다. 탈락 사유 집계를 실어 대량 모델 실패와 엄격했을 뿐인 한 주를 구분하게 합니다 | | `Model Failures` — WARN | `_maybe_send_model_failure_alert` | 호는 나가지만 일부 글을 모델 호출 실패로 잃었을 때 | 실패한 자리는 백필되므로 호가 얇아진 것만 남습니다 | | `Newsletter Clipped` — ALERT | `_maybe_send_clipped_newsletter_alert` | 렌더 크기가 `GMAIL_CLIP_BYTES`를 넘을 때 | 발송은 성공하고 뒤쪽 카드만 안 보입니다(§13) | diff --git a/tests/test_main_notifications.py b/tests/test_main_notifications.py index 37ea498..bef1420 100644 --- a/tests/test_main_notifications.py +++ b/tests/test_main_notifications.py @@ -525,11 +525,31 @@ def test_empty_digest_after_filtering_publishes_alert(self, monkeypatch): assert len(session.sns.published) == 1 assert "Empty Digest" in session.sns.published[0]["Subject"] - def test_a_likely_broken_source_alone_raises_the_crawl_health_alert( - self, monkeypatch + @pytest.mark.parametrize( + "source", + [ + # Fetched fine but offered nothing (candidates == 0): dead, not quiet. + SourceHealth( + url="https://moved.example/feed", + fetcher="RssFetcher", + status=SourceStatus.EMPTY, + candidates=0, + ), + # Fetched fine, but no post's article page could be read. + SourceHealth( + url="https://blocked.example/feed", + fetcher="RssFetcher", + status=SourceStatus.OK, + post_count=3, + unreadable_pages=3, + ), + ], + ids=["likely_broken", "pages_unreadable"], + ) + def test_a_source_that_did_not_fail_alone_raises_the_crawl_health_alert( + self, monkeypatch, source ): - # No source FAILED; one fetched fine but offered nothing (candidates == 0). - # That is the "dead vs quiet" signal, and the handler must page on it. + # No source FAILED; the handler must still page on either signal. self._patch_common(monkeypatch) class _Cfg: @@ -548,16 +568,7 @@ class scraping: session = _FakeSession() monkeypatch.setattr(main, "_boto_sessions", lambda _cfg: (session, session)) monkeypatch.setattr(main, "_setup_aws_env", lambda *a, **k: None) - report = CrawlReport( - sources=[ - SourceHealth( - url="https://moved.example/feed", - fetcher="RssFetcher", - status=SourceStatus.EMPTY, - candidates=0, - ) - ] - ) + report = CrawlReport(sources=[source]) monkeypatch.setattr( main, "_fetch_and_filter_posts",