"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용. ★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다. 자세한 배경은 DEVLOG.md 참고. """ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass from common.logger import LOG _current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None) # jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 # 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다. _MAX_MESSAGE = 500 _MAX_TARGET = 200 @dataclass class CollectIssue: stage: str # 어느 단계에서(예: "naver_place" · "tour_api" · "yanolja" · "static_html") target: str # 무엇을 하다가(URL·검색어 등) error_type: str # 예외 클래스명 message: str # 예외 메시지 @contextmanager def collecting(): """run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다.""" token = _current.set([]) try: yield finally: _current.reset(token) def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue: """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다. collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다 (단발 호출·테스트 호환).""" issue = CollectIssue( stage=stage, target=target[:_MAX_TARGET], error_type=type(ex).__name__, message=str(ex)[:_MAX_MESSAGE], ) issues = _current.get() if issues is not None: issues.append(issue) LOG.w(f"[collect] {stage} 실패(계속) {issue.target}: {issue.error_type}: {issue.message}") return issue def snapshot() -> list[dict]: """지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다.""" issues = _current.get() return [asdict(i) for i in issues] if issues else []