수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
240 lines
11 KiB
Python
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
|