diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index b250ee4..1957dcc 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -412,7 +412,7 @@ async def coverage(place, place_id: str) -> dict: } -async def fetch_one(link): +async def fetch_one(link, category=None): """링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다.""" try: adapter = REGISTRY.get_adapter(link.url) @@ -421,7 +421,7 @@ async def fetch_one(link): LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}") return None, "no_adapter" try: - source = await adapter.fetch(link.url) + source = await adapter.fetch(link.url, category) except Exception as ex: collect_diagnostics.note_issue("fetch", link.url, ex) return None, "failed" @@ -638,7 +638,7 @@ async def _run_collect(job: dict) -> dict: f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략") break - source, outcome = await fetch_one(link) + source, outcome = await fetch_one(link, PlaceCategory(place.category)) fetch_stat[outcome] += 1 if source is None: continue diff --git a/solution/backend/services/collector/base.py b/solution/backend/services/collector/base.py index 825d844..3ef27b2 100644 --- a/solution/backend/services/collector/base.py +++ b/solution/backend/services/collector/base.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional, Protocol, runtime_checkable -from common.enums import LinkChannel +from common.enums import LinkChannel, PlaceCategory # ---- 도메인 예외 ----------------------------------------------------------- @@ -154,4 +154,4 @@ class SourceAdapter(Protocol): def can_handle(self, url: str) -> bool: ... - async def fetch(self, url: str) -> RawSource: ... + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: ... diff --git a/solution/backend/services/collector/mock_adapter.py b/solution/backend/services/collector/mock_adapter.py index 1bca451..733b9ac 100644 --- a/solution/backend/services/collector/mock_adapter.py +++ b/solution/backend/services/collector/mock_adapter.py @@ -14,6 +14,7 @@ URL 규약 (업종을 URL 에서 읽어 결정적으로 동작한다) mock://cafe/cafe-1?channel=naver_place https://mock.test/restaurant/r-1 """ +from typing import Optional from urllib.parse import parse_qs, urlparse from common.category_schema import get_schema @@ -227,7 +228,7 @@ class MockAdapter: return True return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS - async def fetch(self, url: str) -> RawSource: + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: """URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다. 업종을 못 읽으면 예외가 아니라 실패 결과(ok=False)로 돌려준다 — diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 433499e..71f2cf1 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -21,7 +21,7 @@ from typing import Optional import httpx -from common.enums import LinkChannel +from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact, CollectedMedia, RawSource @@ -102,6 +102,18 @@ _WEEKEND_TOKENS = ("주말", "금토", "토일", "공휴일") # 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다. _DAY_TAG = re.compile(rf"({'|'.join(_WEEKDAY_TOKENS + _WEEKEND_TOKENS)})\s*(\([^)]*\))?\s*") +_UNIT_NAME_KEY = { + PlaceCategory.LODGING: "room_type", + PlaceCategory.CAFE: "menu_name", + PlaceCategory.RESTAURANT: "menu_name", + PlaceCategory.CLINIC: "program_name", +} +_UNIT_PRICE_KEY = { + PlaceCategory.CAFE: "menu_price", + PlaceCategory.RESTAURANT: "menu_price", + PlaceCategory.CLINIC: "price_adult", +} + class NaverPlaceAdapter: """네이버 플레이스 상세 → fact·사진 후보.""" @@ -125,7 +137,7 @@ class NaverPlaceAdapter: u = (url or "").lower() return any(h in u for h in _HOSTS) - async def fetch(self, url: str) -> RawSource: + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: channel = LinkChannel.NAVER_PLACE try: place_id = await self._resolve_place_id(url) @@ -143,7 +155,7 @@ class NaverPlaceAdapter: if not base: return RawSource.failure(url, self.id, "응답에 PlaceDetailBase 가 없다", channel) - facts = self._to_facts(base, state) + facts = self._to_facts(base, state, category) media = self._to_media(state) booking_url = self._booking_url(state) @@ -293,7 +305,7 @@ class NaverPlaceAdapter: raise RuntimeError(f"네트워크 오류: {ex}") raise RuntimeError(last) - def _to_facts(self, base: dict, state: dict) -> list[CollectedFact]: + def _to_facts(self, base: dict, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: """★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다.""" facts: list[CollectedFact] = [] @@ -345,10 +357,10 @@ class NaverPlaceAdapter: seen.add(key) facts.append(CollectedFact(key=key, value="false" if negated else "true")) - facts.extend(self._to_unit_facts(state)) + facts.extend(self._to_unit_facts(state, category)) return facts - def _to_unit_facts(self, state: dict) -> list[CollectedFact]: + def _to_unit_facts(self, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: """요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact. ★ 왜 필요한가 @@ -368,6 +380,9 @@ class NaverPlaceAdapter: rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)] rows.sort(key=lambda v: int(v.get("index") or 0)) + name_key = _UNIT_NAME_KEY.get(category, "room_type") + flat_price_key = _UNIT_PRICE_KEY.get(category) if category is not None else None + out: list[CollectedFact] = [] seen_names: list[str] = [] for row in rows: @@ -382,16 +397,17 @@ class NaverPlaceAdapter: if unit_name not in seen_names: seen_names.append(unit_name) - # room_type 은 숙박 스키마의 unit 필수 필드다. 이름 자체가 상품 구분이므로 그대로 싣는다. out.append(CollectedFact( - key="room_type", value=unit_name, scope="unit", unit_name=unit_name, + key=name_key, value=unit_name, scope="unit", unit_name=unit_name, )) # 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다. price = str(row.get("price") or "").strip().replace(",", "") if not price.isdigit(): continue - if any(t in raw_name for t in _WEEKEND_TOKENS): + if flat_price_key: + price_key = flat_price_key + elif any(t in raw_name for t in _WEEKEND_TOKENS): price_key = "weekend_price" elif any(t in raw_name for t in _WEEKDAY_TOKENS): price_key = "weekday_price" diff --git a/solution/backend/services/collector/static_html_adapter.py b/solution/backend/services/collector/static_html_adapter.py index 19d3b13..00036e7 100644 --- a/solution/backend/services/collector/static_html_adapter.py +++ b/solution/backend/services/collector/static_html_adapter.py @@ -47,7 +47,7 @@ from urllib.robotparser import RobotFileParser import httpx -from common.enums import LinkChannel +from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact, CollectedMedia, RawSource @@ -200,7 +200,7 @@ class StaticHtmlAdapter: return False return not any(host == d or host.endswith("." + d) for d in _DENY_HOSTS) - async def fetch(self, url: str) -> RawSource: + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: channel = self._channel(url) allowed, why = await self._robots_allows(url) diff --git a/solution/backend/services/collector/tour_api_adapter.py b/solution/backend/services/collector/tour_api_adapter.py index fe92984..90089ca 100644 --- a/solution/backend/services/collector/tour_api_adapter.py +++ b/solution/backend/services/collector/tour_api_adapter.py @@ -33,7 +33,7 @@ from urllib.parse import unquote, urlencode import httpx -from common.enums import LinkChannel +from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from config.server_configs import external_api_config from services.collector.base import CollectedFact, CollectedMedia, RawSource @@ -104,7 +104,7 @@ class TourApiAdapter: return True return any(h in u for h in _HOSTS) and bool(_COTID.search(u)) - async def fetch(self, url: str) -> RawSource: + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: channel = LinkChannel.ETC key = (external_api_config.tour_api_key or "").strip() if not key: diff --git a/solution/backend/services/collector/yanolja_adapter.py b/solution/backend/services/collector/yanolja_adapter.py index 707aae4..73896d9 100644 --- a/solution/backend/services/collector/yanolja_adapter.py +++ b/solution/backend/services/collector/yanolja_adapter.py @@ -23,7 +23,7 @@ from typing import Optional from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright -from common.enums import LinkChannel +from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact, CollectedMedia, RawSource @@ -249,7 +249,7 @@ class YanoljaAdapter: def can_handle(self, url: str) -> bool: return bool(DETAIL_URL_RE.search((url or "").lower())) - async def fetch(self, url: str) -> RawSource: + async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: pw, browser, page = await _new_page() try: await page.goto(url, wait_until="domcontentloaded", timeout=60000)