"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다. ★ TourAPI 로 이미 있는 맛집은 몇 건이든(군산 절골길 18처럼 59건이어도) 그대로 전부 보여준다 — 이 모듈은 그중 어떤 것도 지우거나 숨기지 않는다(2026-09-14 사용자 확정). Perplexity 지역검색 상위 10개 이름 중 TourAPI(또는 이전에 이미 크롤링해 둔 것)에 없는 이름만 네이버에서 크롤링해 **추가**한다 — "상위 10개"는 Perplexity 검색 후보의 상한일 뿐, 최종 화면에 보이는 개수의 상한이 아니다. ★ 네이버 URL 확보는 `services/external/naver_place_lookup.py`(상호명+지역으로 네이버 자체 검색 → place id)를 그대로 재사용한다 — Perplexity 도메인필터 재검색으로 시도했다가 실측(2026-09-14, 군산시 6곳 중 0곳 성공)에서 명중률이 낮아 이 기존 모듈로 바꿨다. ★ docs/DECISIONS.md 1-1 예외 처리. 설계: tmp/superpowers/specs/2026-09-14-nearby-restaurant-naver-enrichment-design.md 봇 탐지 우회는 하지 않는다 — 막히면 그 업체만 포기한다. """ 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: """이름 유사도 판정. 표기 차이와 지점명 접미사("...본점")는 같은 곳으로 본다. ★ 부분 문자열 포함으로 판정한다 — 완전 일치만 보면 "이든식당"과 "이든식당 본점"이 다른 곳으로 갈려 TourAPI에 이미 있는 곳을 중복으로 다시 크롤링한다. """ 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. ★ distance_m 은 항상 None 이다 — 지역명 검색으로 찾은 업체라 업장 좌표 기준 거리를 모른다. local_content_service 의 body["distance_m"] 직접 접근 규약을 지키려면 키 자체는 있어야 한다(값만 비운다). """ 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). 둘 중 하나라도 없으면 None. ★ 외부 호출 없는 순수 계산이다 — 좌표는 이미 fetch_summary()가 같은 응답에서 받아 온 값이라 이 계산에 드는 비용은 없다(common.utils.geo.haversine_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 자신의 좌표. 크롤링한 맛집과의 거리 계산용으로만 쓴다. ★ 순환 import 회피: story_service → 이 모듈로 이어지는 사슬이 있어 지연 import 한다. """ 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)에 없는 것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다. restaurant_refs: [(content_id, distance_m, hidden), ...] — 기존 연결 + 이번에 새로 크롤링한 것 전부. 기존 것이 이미 맵에 있으면 손대지 않고, 없는 것(새로 추가한 것, 또는 과거에 맵 갱신 없이 만들어진 것)만 채운다 — 그래서 자연히 자가복구도 된다. ★ 캔버스(스냅샷)는 `place_area_refs`가 아니라 이 맵만 읽는다(services/snapshot.py:: _site_places). `place_area_refs`에만 쓰고 여기를 안 채우면, DB에는 들어가도 화면에는 안 나온다. ★ 순환 import 회피: local_content_service → story_service → 이 모듈로 이어지는 사슬이 있어 지연 import 한다(story_service.run_local_sync 의 관례와 동일). """ 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개 이름 중 아직 없는 곳만 네이버에서 크롤링해 추가한다. 기존 연결을 지우거나 숨기지 않는다. 흐름: ① Perplexity 로 이 지역 맛집 상위 10개 이름을 받는다 → ② 이름마다 이 place 에 이미 연결된 맛집(TourAPI 또는 이전 크롤링분)과 유사도 매칭 — 있으면 건너뛰고(중복 크롤링 방지), 없으면 네이버에서 크롤링해 새로 연결한다. 실패해도 예외를 던지지 않는다. """ 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