o2o-site-AEO/solution/backend/services/local_restaurant_enrichment.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

193 lines
7.9 KiB
Python

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