여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
193 lines
7.9 KiB
Python
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
|