From 951e451ef895aae2b31784a47be2487b4a385af0 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 23 Sep 2026 13:17:28 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/backend:=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EC=9D=B4=20=EC=9D=8C=EC=8B=9D=EC=A0=90=C2=B7=EC=B9=B4?= =?UTF-8?q?=ED=8E=98=20=EC=9A=94=EA=B8=88=ED=91=9C=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=EC=A7=B8=EB=A1=9C=20=EB=B0=98=EB=A0=A4=20=E2=80=94=20=EC=97=85?= =?UTF-8?q?=EC=A2=85=EB=B3=84=20unit=20key=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _to_unit_facts() 가 업종을 안 가리고 room_type·weekday_price/weekend_price 로만 냈다. 그 key 는 숙박 스키마에만 있어서, 음식점·카페·클리닉은 메뉴/프로그램 요금표가 FACT_INVALID_KEY 로 전량 거부됐다(실측: "도플로" fact 19건 중 19건 반려). - naver_place_adapter.py: _UNIT_NAME_KEY·_UNIT_PRICE_KEY 로 업종별 key 매핑 (숙박 room_type/weekday·weekend_price, 카페·음식점 menu_name/menu_price, 클리닉 program_name/price_adult) - SourceAdapter.fetch() 계약에 category 파라미터 추가, 어댑터 5개 시그니처 반영 - collect_service.py: fetch_one() 에 place.category 전달 검증: 재수집 후 stored 0 → 35(음식점), test_collector·test_category_schema· test_fact_api·test_tour_api_adapter 173 passed --- solution/backend/services/collect_service.py | 6 ++-- solution/backend/services/collector/base.py | 4 +-- .../services/collector/mock_adapter.py | 3 +- .../services/collector/naver_place_adapter.py | 34 ++++++++++++++----- .../services/collector/static_html_adapter.py | 4 +-- .../services/collector/tour_api_adapter.py | 4 +-- .../services/collector/yanolja_adapter.py | 4 +-- 7 files changed, 38 insertions(+), 21 deletions(-) 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)