feat(news): crawl-24x7 사이클 2 — B-2/B-3/C-1/C-2/C-3/C-5 (마이그 324-326)
- 채널 인지화: news_sources.source_channel(324, documents enum 재사용) → 문서 생성 정체성(_doc_identity)·embed/chunk 30일 게이트(crawl=전량 색인)· extract 후속 override(crawl→classify, preview 스킵) 분기. - B-2 Guardian Open Platform: API 디스패치(호스트 분기, 미지 호스트=명시 실패) + show-fields=bodyText 전문 어댑터. fixture live 박제 + call-shape 테스트. - B-3 구독지: playwright-fetcher 격리 컨테이너(동시 1·요청당 브라우저·storage_state ro mount) + politeness 사람속도(30-60s) 브라우저 경로 + fulltext 인증 라우팅 (내용 기반 probe 게이트·relogin_requested 소비=open-스킵보다 앞·본문 페이월 마커 게이트) + source_health probe 컬럼(325) + 세션 박제 스크립트(맥북용). - C-2 KOSHA: 3 API live 검증·fixture 박제(board/attach/guide) — 재해사례 daily diff +첨부 PDF/HWP→extract 파이프라인, GUIDE 일일 cap 점진 백필(silent cap 금지 로그). 키는 URL 직결합(재인코딩 함정 회피). daily 06:40 KST. - C-3 정적 코퍼스: National Board 86 + TWI job-knowledge 153 일괄 CLI(멱등·politeness ·crawl_raw 보존·fulltext_worker 승격 필드 규약 동일). - C-1/C-5 시드(326): 전 URL live 검증 — UK HSE(feed-full)/안전신문/고용노동부 3종 (rss/*.do)/OSHA/EU-OSHA(후보)/SEP/1000-Word(feed-full)/Doing Philosophy/Aeon/Psyche (skip-video quirk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"body":{"pageNo":1,"totalCount":1,"numOfRows":5,"items":{"item":[{"filenm":"컨베이어에 끼임.pdf","filepath":"https://portal.kosha.or.kr/openapi/v1/file/down/stdboard/B2025022104002/202605281621537G75H2/D0801000010001","boardno":"202605281621537G75H2"}]}},"header":{"resultCode":"00","resultMsg":"NORMAL_CODE"}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"body":{"pageNo":1,"totalCount":6334,"numOfRows":3,"items":{"item":[{"business":"제조업","contents":"2026.01.00(월) 07:30경, 경기도 소재 OOOO(주)에서 재해자가 골재 이송 컨베이어 상부의 이물질을 제거하던 중,다리가 컨베이어 벨트와 테일 풀리 (Tail Pulley)* 사이에 끼임 *컨베이어의 아래쪽 끝단에서 회전하며 벨트를 순환시키는 원통형 기계장치","atcflcnt":1,"keyword":"컨베이어에 끼임","boardno":"202605281621537G75H2"},{"business":"건설업","contents":"2025. 8. 00. (금) 11:12 경 경기도 소재 OOO 신축공 사현장에서 데크플레이트 설치 중 밟고 있던 미고정 데크플레이트가 탈락하며 약 7m 높이에서 추락함","atcflcnt":1,"keyword":"데크플레이트 설치 작업 중 추락","boardno":"20260528162031VZLE93"},{"business":"건설업","contents":"2025. 06. 00.(금) 12:35경, 경북 봉화군 소재 (주)OOOO 침전저류지 현장에서 타워크레인 전도 후 매립된 케이크*(오염토)를 굴착 및 운반 작업 중, 사면의 토사와 타워크레인 기초구조물이 무너지며 하단에서 작업 중이던 굴착기가 매몰됨 * 분말 상태의 원료에서 아연을 채취한 후 남은 중금속 부산물(산화칼슘, 납, 산화철, 황산 등)을 장기간 매립하여 만들어지는 고체 형태의 오염 토양 덩어리","atcflcnt":1,"keyword":"사면 굴착 작업 중 매몰","boardno":"20260527153100O7QX25"}]}},"header":{"resultCode":"00","resultMsg":"NORMAL_CODE"}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"body":{"pageNo":1,"totalCount":1039,"numOfRows":3,"items":{"item":[{"techGdlnNm":"구리에 대한 작업환경측정,분석 기술지침","techGdlnNo":"A-1-2018","techGdlnOfancYmd":"2018-11-27","fileDownloadUrl":"https://portal.kosha.or.kr/openapi/v1/file/down/FL00015883045/7"},{"techGdlnNm":"마그네슘에 대한 작업환경측정,분석 기술지침","techGdlnNo":"A-4-2018","techGdlnOfancYmd":"2018-11-27","fileDownloadUrl":"https://portal.kosha.or.kr/openapi/v1/file/down/FL00015883165/3"},{"techGdlnNm":"백금에 대한 작업환경측정,분석 기술지침","techGdlnNo":"A-6-2018","techGdlnOfancYmd":"2018-11-27","fileDownloadUrl":"https://portal.kosha.or.kr/openapi/v1/file/down/FL00015883187/3"}]}},"header":{"resultCode":"00","resultMsg":"NORMAL_CODE"}}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""crawl-24x7 사이클 2 — 순수 함수/형태 회귀 테스트 (DB 불요).
|
||||
|
||||
Guardian 호출 형태 + fixture 응답 파싱 + 채널 정체성 + B-5 quirk.
|
||||
fixture = tests/fixtures/guardian_open_platform_search_response.json
|
||||
(2026-06-10 실키 live 박제, api-key 응답 본문 미포함 확인 — [[feedback_external_api_fixture_first]]).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from workers.news_collector import (
|
||||
_article_hash,
|
||||
_doc_identity,
|
||||
_guardian_request,
|
||||
_normalize_category,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "guardian_open_platform_search_response.json"
|
||||
|
||||
|
||||
def _make_source(**kw):
|
||||
"""ORM 인스턴스 없이 속성만 흉내 (식별성 함수는 속성 접근만 사용)."""
|
||||
class S:
|
||||
pass
|
||||
s = S()
|
||||
s.source_channel = kw.get("source_channel", "news")
|
||||
s.parser_quirk = kw.get("parser_quirk")
|
||||
return s
|
||||
|
||||
|
||||
class TestGuardianCallShape:
|
||||
def test_request_shape_matches_fixture_recipe(self):
|
||||
"""fixture 박제 시 사용한 호출과 단일 source-of-truth 정합
|
||||
([[feedback_fixture_first_call_shape]])."""
|
||||
endpoint, params = _guardian_request(
|
||||
"https://content.guardianapis.com/search?section=world", "KEY"
|
||||
)
|
||||
assert endpoint == "https://content.guardianapis.com/search"
|
||||
assert params["section"] == "world"
|
||||
assert params["show-fields"] == "bodyText,trailText"
|
||||
assert params["order-by"] == "newest"
|
||||
assert params["api-key"] == "KEY"
|
||||
|
||||
def test_feed_url_query_overridden_by_fixed_fields(self):
|
||||
# feed_url 에 show-fields 가 잘못 박혀 있어도 고정 필드가 이긴다 (dict merge 순서)
|
||||
_, params = _guardian_request(
|
||||
"https://content.guardianapis.com/search?section=world&show-fields=headline", "K"
|
||||
)
|
||||
assert params["show-fields"] == "bodyText,trailText"
|
||||
|
||||
|
||||
class TestGuardianFixtureParsing:
|
||||
def test_fixture_response_shape(self):
|
||||
payload = json.loads(FIXTURE.read_text())["response"]
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["results"], "fixture 에 결과 0건"
|
||||
for item in payload["results"]:
|
||||
assert item["webTitle"].strip()
|
||||
assert item["webUrl"].startswith("https://")
|
||||
assert "webPublicationDate" in item
|
||||
assert "sectionName" in item
|
||||
fields = item.get("fields") or {}
|
||||
assert "bodyText" in fields and "trailText" in fields
|
||||
|
||||
def test_fixture_bodytext_is_fulltext_grade(self):
|
||||
payload = json.loads(FIXTURE.read_text())["response"]
|
||||
# 전문 게이트(200자)를 fixture 가 통과해야 어댑터 is_full 경로가 산다
|
||||
assert any(len(i["fields"]["bodyText"]) >= 200 for i in payload["results"])
|
||||
|
||||
def test_fixture_contains_no_api_key(self):
|
||||
assert "api-key" not in FIXTURE.read_text()
|
||||
|
||||
|
||||
class TestChannelIdentity:
|
||||
def test_news_channel_unchanged(self):
|
||||
ident = _doc_identity(_make_source(source_channel="news"), "경향신문", "Society")
|
||||
assert ident == {
|
||||
"path_prefix": "news",
|
||||
"ai_domain": "News",
|
||||
"ai_tags": ["News/경향신문/Society"],
|
||||
}
|
||||
|
||||
def test_crawl_channel_domain_identity(self):
|
||||
ident = _doc_identity(_make_source(source_channel="crawl"), "TWI", "Engineering")
|
||||
assert ident["path_prefix"] == "crawl"
|
||||
assert ident["ai_domain"] == "Engineering"
|
||||
assert ident["ai_tags"] == ["Engineering/TWI"]
|
||||
|
||||
def test_crawl_channel_unknown_category_falls_back(self):
|
||||
ident = _doc_identity(_make_source(source_channel="crawl"), "X", "Other")
|
||||
assert ident["ai_domain"] == "Domain"
|
||||
|
||||
def test_category_map_has_domain_axes(self):
|
||||
assert _normalize_category("안전") == "Safety"
|
||||
assert _normalize_category("Engineering") == "Engineering"
|
||||
assert _normalize_category("철학") == "Philosophy"
|
||||
|
||||
|
||||
class TestSkipVideoQuirk:
|
||||
PATTERN = re.compile(r"/videos?/")
|
||||
|
||||
def test_video_urls_match(self):
|
||||
assert self.PATTERN.search("https://psyche.co/videos/some-film")
|
||||
assert self.PATTERN.search("https://aeon.co/video/another")
|
||||
|
||||
def test_article_urls_pass(self):
|
||||
assert not self.PATTERN.search("https://psyche.co/ideas/how-to-think")
|
||||
|
||||
|
||||
class TestArticleHashStability:
|
||||
def test_static_corpus_hash_deterministic(self):
|
||||
a = _article_hash("Creep and Creep Failures", "static", "National Board 기술 아티클")
|
||||
b = _article_hash("Creep and Creep Failures", "static", "National Board 기술 아티클")
|
||||
assert a == b and len(a) == 32
|
||||
Reference in New Issue
Block a user