"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다.""" import re from datetime import datetime, timezone from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import area_contents, place_area_refs from common.enums import AREA_KIND, DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource from common.logger import LOG from common.utils.geo import haversine_m from crud.place_content_crud import PlaceContentCRUD from services.collector.naver_place_adapter import NaverPlaceAdapter from services.external import naver_place_lookup, perplexity from services.external.restaurant_discovery import search_region_restaurants _NORM_STRIP = re.compile(r"[\s,·.\-_'\"()&]") _BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude") def normalize_name(name: str) -> str: """비교용 정규화.""" return _NORM_STRIP.sub("", (name or "")).lower() def is_same_restaurant(a: str, b: str) -> bool: """이름 유사도 판정.""" na, nb = normalize_name(a), normalize_name(b) if not na or not nb: return False return na == nb or na in nb or nb in na def to_area_content_body(summary: dict) -> dict: """NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict.""" out = { "contentid": summary["place_id"], "content_type": LocalContentType.RESTAURANT.value, "distance_m": None, "name": summary["name"], "searchQuery": summary["name"], } if summary.get("address"): out["location"] = summary["address"] if summary.get("latitude"): out["latitude"] = summary["latitude"] if summary.get("longitude"): out["longitude"] = summary["longitude"] if summary.get("imageUrl"): out["imageUrl"] = summary["imageUrl"] return out def _as_float(value) -> float | None: try: return float(value) if value not in (None, "") else None except (TypeError, ValueError): return None def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) -> int | None: """업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m).""" if place_coords is None: return None lat, lng = _as_float(summary.get("latitude")), _as_float(summary.get("longitude")) if lat is None or lng is None: return None return round(haversine_m(place_coords[0], place_coords[1], lat, lng)) async def _place_coordinates(place_id) -> tuple[float, float] | None: """이 place 자신의 좌표.""" from services.local_content_service import LocalContentService place = await LocalContentService()._load_place(place_id) if place is None: return None lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None)) if lat is None or lng is None: return None return lat, lng async def _sync_site_personalization(place_id, restaurant_refs: list) -> None: """`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는 것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다.""" if not restaurant_refs: return from services.local_content_service import LocalContentService from services.snapshot import _site_places places_map = dict(await _site_places(place_id)) changed = False for content_id, distance_m, hidden in restaurant_refs: key = str(content_id) if key in places_map: continue places_map[key] = {"kind": "restaurant", "distanceMeters": distance_m, "hidden": bool(hidden)} changed = True if changed: await LocalContentService()._write_site_places(place_id, places_map) async def enrich_place_restaurants(place_id, region_label: str, region_code: str | None = None) -> dict: """이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중 아직 없는 곳만 네이버에서 크롤링해 추가한다.""" stats = {"matched": 0, "added": 0, "checked": 0, "skipped": ""} if not perplexity.is_configured(): stats["skipped"] = "PERPLEXITY_API_KEY 미설정" return stats if not (region_label or "").strip(): stats["skipped"] = "region_label 없음" return stats names = await search_region_restaurants(region_label) if not names: stats["skipped"] = "Perplexity 검색 결과 없음" return stats crud = PlaceContentCRUD() err, rows = await DB_SESSION_MNG.execute_lambda( place_area_refs.DBType(), DBWRType.DB_READ.value, lambda s: crud.list_by_place(s, place_id), ) if err != ErrorType.SUCCESS: stats["skipped"] = "기존 목록 조회 실패" return stats existing = [r for r in (rows or []) if int(r.content_type) == LocalContentType.RESTAURANT.value] existing_titles = [r.title for r in existing if r.title] now = datetime.now(timezone.utc) place_coords = await _place_coordinates(place_id) new_entries: list = [] # [(content_id, distance_m), ...] for name in names: stats["checked"] += 1 if any(is_same_restaurant(name, title) for title in existing_titles): stats["matched"] += 1 continue naver_id = await naver_place_lookup.find_place_id(name, region_label) if not naver_id: continue summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(naver_id)) if not summary: # naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다. LOG.w(f"[restaurant_enrich] '{name}' place={naver_id} 상세 조회 실패 — 포기") continue body = to_area_content_body(summary) content_values = { "source": LocalSource.NAVER_CRAWL.value, "external_id": body["contentid"], "content_type": body["content_type"], "kind": AREA_KIND.get(body["content_type"]), "title": body["name"], "body": {k: v for k, v in body.items() if k not in _BODY_DROP}, "latitude": _as_float(body.get("latitude")), "longitude": _as_float(body.get("longitude")), "region_code": region_code, "status": LocalContentStatus.PUBLISHED.value, "display_end_at": None, "collected_at": now, } write_err = await DB_SESSION_MNG.execute_lambda_run( [area_contents.DBType()], [lambda s, v=content_values: crud.upsert_content(s, v)], ) if write_err != ErrorType.SUCCESS: continue err_i, rows_i = await DB_SESSION_MNG.execute_lambda( area_contents.DBType(), DBWRType.DB_READ.value, lambda s, e=body["contentid"]: crud.find_content_id(s, LocalSource.NAVER_CRAWL.value, e), ) if err_i != ErrorType.SUCCESS or not rows_i: continue content_id = rows_i[0] distance_m = _distance_to_place(place_coords, summary) await DB_SESSION_MNG.execute_lambda_run( [place_area_refs.DBType()], [lambda s, cid=content_id, d=distance_m: crud.upsert_ref(s, place_id, cid, d)], ) existing_titles.append(name) new_entries.append((content_id, distance_m)) stats["added"] += 1 LOG.i(f"[restaurant_enrich] place={place_id} '{name}' 네이버 크롤링으로 추가" f"{f' (거리 {distance_m}m)' if distance_m is not None else ''}") restaurant_refs = [(r.local_content_id, r.distance_m, r.hidden) for r in existing] restaurant_refs += [(cid, dist, False) for cid, dist in new_entries] await _sync_site_personalization(place_id, restaurant_refs) return stats