o2o-site-AEO/solution/backend/services/local_restaurant_enrichment.py
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

240 lines
11 KiB
Python

"""주변 맛집 보강 — 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