"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.""" 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: """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.""" 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]: """지금까지 쌓인 실패 목록.""" issues = _current.get() return [asdict(i) for i in issues] if issues else []