o2o-site-AEO/solution/backend/services/collector/base.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

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: ...