여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
119 lines
4.2 KiB
Python
119 lines
4.2 KiB
Python
"""수집 어댑터 계약 — '어디서 긁어오든 결과 모양은 하나' 를 강제한다."""
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Protocol, runtime_checkable
|
|
|
|
from common.enums import LinkChannel, PlaceCategory
|
|
|
|
|
|
# 도메인 예외
|
|
class CollectError(RuntimeError):
|
|
"""수집 계층 공통 예외."""
|
|
|
|
|
|
class AdapterNotFound(CollectError):
|
|
"""이 URL 을 처리할 어댑터가 없다."""
|
|
|
|
|
|
class AdapterDisabled(CollectError):
|
|
"""어댑터는 존재하나 이 환경에서 꺼져 있다(법무 검토 전 등)."""
|
|
|
|
|
|
class FetchFailed(CollectError):
|
|
"""가져오기 자체가 실패했다(네트워크·파싱)"""
|
|
|
|
|
|
# 수집 결과 구성요소
|
|
@dataclass
|
|
class CollectedFact:
|
|
"""수집된 fact 후보 1건."""
|
|
|
|
key: str
|
|
value: Optional[str]
|
|
scope: str = "place" # place | unit
|
|
unit_name: Optional[str] = None # scope='unit' 일 때 어느 단위 것인지
|
|
source_url: str = "" # RawSource.__post_init__ 이 채운다
|
|
|
|
def __post_init__(self):
|
|
if self.scope not in ("place", "unit"):
|
|
raise CollectError(f"CollectedFact.scope 값 오류: {self.scope} (place|unit)")
|
|
if self.scope == "unit" and not self.unit_name:
|
|
raise CollectError(f"unit 스코프 fact 는 unit_name 이 필요하다: key={self.key}")
|
|
|
|
|
|
@dataclass
|
|
class CollectedMedia:
|
|
"""수집된 사진 1장."""
|
|
|
|
origin_url: str
|
|
label: Optional[str] = None
|
|
unit_name: Optional[str] = None
|
|
source_url: str = "" # RawSource.__post_init__ 이 채운다
|
|
license: Optional[str] = None # 출처가 밝힌 이용 조건 코드.
|
|
|
|
def __post_init__(self):
|
|
if not (self.origin_url or "").strip():
|
|
raise CollectError("CollectedMedia.origin_url 이 비었다 — 출처 없는 사진은 담지 않는다")
|
|
|
|
|
|
@dataclass
|
|
class RawSource:
|
|
"""어댑터 1회 수집 결과."""
|
|
|
|
url: str
|
|
adapter_id: str
|
|
channel: LinkChannel = LinkChannel.ETC
|
|
fetched_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
|
|
ok: bool = True
|
|
error: Optional[str] = None
|
|
html: Optional[str] = None # 원문(디버깅·재파싱용)
|
|
text: Optional[str] = None # 태그 걷어낸 본문
|
|
facts: list[CollectedFact] = field(default_factory=list)
|
|
media: list[CollectedMedia] = field(default_factory=list)
|
|
# 수집 중 **그 채널이 스스로 알려준** 예약 주소.
|
|
booking_url: Optional[str] = None
|
|
|
|
def __post_init__(self):
|
|
if not (self.url or "").strip():
|
|
raise CollectError("RawSource.url 이 비었다 — 출처 없는 수집 결과는 만들 수 없다")
|
|
if not (self.adapter_id or "").strip():
|
|
raise CollectError("RawSource.adapter_id 가 비었다")
|
|
# 출처 강제: 개별 항목이 출처를 안 들고 있으면 여기서 찍는다.
|
|
for item in (*self.facts, *self.media):
|
|
if not item.source_url:
|
|
item.source_url = self.url
|
|
|
|
@property
|
|
def source_url(self) -> str:
|
|
"""이 결과 전체의 출처."""
|
|
return self.url
|
|
|
|
def fact_map(self) -> dict:
|
|
"""사업장 단위 fact 만 {key: value} 로."""
|
|
return {f.key: f.value for f in self.facts if f.scope == "place"}
|
|
|
|
def unit_names(self) -> list[str]:
|
|
"""수집된 단위(객실·메뉴·프로그램) 이름 목록 — 등록 순서 유지."""
|
|
seen: list[str] = []
|
|
for f in self.facts:
|
|
if f.scope == "unit" and f.unit_name and f.unit_name not in seen:
|
|
seen.append(f.unit_name)
|
|
return seen
|
|
|
|
@classmethod
|
|
def failure(cls, url: str, adapter_id: str, error: str, channel: LinkChannel = LinkChannel.ETC) -> "RawSource":
|
|
"""실패 결과."""
|
|
return cls(url=url, adapter_id=adapter_id, channel=channel, ok=False, error=error)
|
|
|
|
|
|
# 어댑터 계약
|
|
@runtime_checkable
|
|
class SourceAdapter(Protocol):
|
|
"""수집 소스 어댑터."""
|
|
|
|
id: str
|
|
|
|
def can_handle(self, url: str) -> bool: ...
|
|
|
|
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: ...
|