[fix] solution/backend: 네이버 플레이스 수집이 음식점·카페 요금표를 통째로 반려 — 업종별 unit key 분기
_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
This commit is contained in:
parent
c366361513
commit
951e451ef8
@ -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
|
||||
|
||||
@ -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: ...
|
||||
|
||||
@ -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)로 돌려준다 —
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user