o2o-site-AEO/backend/services/external/tour_lookup.py
Mina Choi 6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00

244 lines
11 KiB
Python

"""TourAPI 콘텐츠 조회 — 검증된 상호·좌표로 contentId 를 직접 해석한다.
★ 왜 Perplexity 에 맡기지 않나 (services/collect_service.discover_naver_place 와 같은 이유)
우리는 이미 **이 가게가 누구인지 안다** — 동일 업소 검증을 통과한 상호와 좌표가 있다.
추측할 이유가 없다. 게다가 채널 발견 프롬프트는 야놀자·여기어때·네이버만 찾으므로
TourAPI URL 은 애초에 그 경로로 들어올 수 없다.
★ 남의 가게를 붙이지 않는 것이 여기서 가장 비싼 실수다
상호만 비슷한 다른 업소를 공식 채널로 등록하면, 그 집 객실 요금이 우리 사장님 사이트에 실린다.
그래서 두 관문을 **모두** 통과해야 등록한다:
① 정규화한 상호가 일치(포함 관계 허용 — "롯데호텔 월드" ↔ "롯데호텔월드")
② 좌표 거리가 MAX_DISTANCE_M 이내(좌표를 모르면 이 관문은 건너뛴다)
하나라도 어긋나면 None 을 돌려준다. 자동 등록하지 않는다.
★ 업종 매핑
TourAPI 는 카페를 별도 타입으로 두지 않는다 — 음식점(39)의 소분류(cat3=A05020900)다.
그래서 카페·음식점은 같은 contentTypeId 로 조회하고, 어느 쪽인지는 우리 업종 코드가 정한다.
"""
import math
import re
from typing import Optional
from urllib.parse import unquote, urlencode
import httpx
from common.enums import PlaceCategory
from common.logger import LOG
from config.server_configs import external_api_config
# ★ 출처 주소 규칙은 어댑터 한 곳에서만 정의한다. 두 곳이 다른 URL 을 만들면
# 같은 레코드가 다른 출처로 기록돼 재수집 때 중복 fact 가 생긴다.
from services.collector.tour_api_adapter import SOURCE_URL
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
REQUEST_TIMEOUT = 20
# 우리 업종 → TourAPI contentTypeId. 관광체험은 타입이 여러 개로 갈려(12·14·28) 단정할 수 없으므로 뺀다.
CATEGORY_TO_CONTENT_TYPE = {
PlaceCategory.LODGING: "32",
PlaceCategory.CAFE: "39",
PlaceCategory.RESTAURANT: "39",
}
# 같은 업소로 볼 좌표 거리 상한. 대형 호텔은 등록 좌표가 정문/로비로 갈려 수백 m 벌어진다 —
# 너무 좁히면 맞는 업소를 놓치고, 너무 넓히면 옆 건물 가게가 붙는다.
MAX_DISTANCE_M = 500
# 상호를 짧혀가며 다시 물어보는 최대 횟수. ★ 무한정 짧히면 "그래비티" 같은 한 토큰까지 가서
# 전혀 다른 업소가 후보로 올라온다 — 게이트가 막아주긴 하지만 호출만 낭비된다.
MAX_QUERY_ATTEMPTS = 4
def normalize(text: str) -> str:
"""상호 대조용 정규화. 공백·기호를 걷어내고 소문자로.
★ naver_place_lookup._normalize 와 같은 규칙을 쓴다 — 두 조회가 다른 기준으로
'일치' 를 판정하면 한쪽만 붙는 업소가 생긴다.
"""
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
def _name_matches(query_name: str, candidate: str) -> bool:
"""정규화 후 한쪽이 다른 쪽을 포함하면 같은 업소로 본다.
TourAPI 표기가 우리 상호보다 길거나 짧은 경우가 흔하다
(실측: '가재와곰' ↔ '가재와곰펜션', '롯데호텔 월드' ↔ '롯데호텔월드').
★ 다만 너무 짧은 상호는 포함 판정이 헐거워지므로 2자 이하면 완전 일치만 인정한다.
"""
a, b = normalize(query_name), normalize(candidate)
if not a or not b:
return False
if min(len(a), len(b)) <= 2:
return a == b
return a in b or b in a
def _distance_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""두 좌표의 거리(m). 국내 범위라 하버사인이면 충분하다."""
r = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(h))
def is_configured() -> bool:
return bool((external_api_config.tour_api_key or "").strip())
def content_url(content_id: str) -> str:
"""이 레코드를 가리키는 출처 주소(인증키 없음). 어댑터의 SOURCE_URL 과 같은 규칙이다."""
return SOURCE_URL.format(content_id=content_id)
async def _search(client: httpx.AsyncClient, key: str, **params) -> list[dict]:
query = urlencode(
{"serviceKey": unquote(key), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
"_type": "json", "numOfRows": "20", "pageNo": "1", **params},
safe="",
)
res = await client.get(f"{BASE_URL}/searchKeyword2?{query}")
if res.status_code != 200:
raise RuntimeError(f"searchKeyword2 HTTP {res.status_code}")
try:
payload = res.json()
except ValueError:
raise RuntimeError(f"searchKeyword2 응답이 JSON 이 아니다: {res.text[:160]}")
header = payload.get("response", {}).get("header", {})
code = str(header.get("resultCode") or "")
if code not in ("0000", "00"):
raise RuntimeError(f"searchKeyword2 실패 [{code}] {header.get('resultMsg')}")
items = (payload.get("response", {}).get("body", {}).get("items") or {}).get("item")
if isinstance(items, dict):
return [items]
return items or []
def _query_candidates(name: str) -> list[str]:
"""검색어 후보 — 전체 상호부터 시작해 **뒤 토큰을 하나씩 떼며** 짧게 만든다.
★ 왜 필요한가 (2026-08-31 실측)
searchKeyword2 는 토큰을 AND 로 묶는 것처럼 동작한다. 그래서 우리 상호가 등록명보다
길면 **0건**이 나온다:
"그래비티 조선 서울 판교 오토그래프 컬렉션" → 0건
"그래비티 조선" → 1건 (등록명 '그래비티 조선 서울 판교')
브랜드 수식어(오토그래프 컬렉션·컬렉션 바이 …)가 뒤에 붙는 호텔에서 늘 생기는 문제라
한 번 실패하고 마는 대신 짧혀가며 다시 묻는다.
★ 짧아질수록 남의 가게가 걸릴 위험이 커지지만, 최종 판정은 여전히
상호 일치 + 좌표 게이트가 한다. 여기서는 후보를 넓히기만 한다.
"""
tokens = [t for t in re.split(r"\s+", (name or "").strip()) if t]
if not tokens:
return []
out: list[str] = []
for end in range(len(tokens), 0, -1):
q = " ".join(tokens[:end])
if len(normalize(q)) >= 2 and q not in out:
out.append(q)
if len(out) >= MAX_QUERY_ATTEMPTS:
break
return out
_TYPE_LABEL = {"32": "숙박", "39": "음식점·카페", "12": "관광지", "14": "문화시설", "28": "레포츠", "38": "쇼핑"}
async def _warn_if_other_content_type(key: str, name: str, expected: str) -> None:
"""같은 상호가 **다른 콘텐츠 타입**으로 등록돼 있으면 로그로 알린다.
★ 조회 결과를 바꾸지 않는다. 업종이 다르면 스키마도 달라서, 숙박 fact 를 카페 사업장에
밀어 넣어봐야 대부분 스키마 밖 key 로 거부된다(실측: 3건 중 1건만 저장됐다).
고쳐야 할 것은 조회가 아니라 **사업장 업종 등록**이므로, 사람이 볼 수 있게 남기기만 한다.
"""
try:
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
for query in _query_candidates(name):
found = await _search(client, key, keyword=query)
for item in found:
other = str(item.get("contenttypeid") or "")
if other and other != expected and _name_matches(name, str(item.get("title") or "")):
LOG.w(
f"[tour_lookup] ★ 업종 불일치 — '{name}' 은 TourAPI 에 "
f"{_TYPE_LABEL.get(other, other)}({other}) 로 등록돼 있는데 "
f"이 사업장은 {_TYPE_LABEL.get(expected, expected)}({expected}) 로 조회했다. "
f"사업장 업종 등록을 확인하세요."
)
return
if found:
return
except (httpx.HTTPError, RuntimeError):
return # 진단용이라 실패해도 조용히 넘어간다
async def find_content_id(
name: str,
category: PlaceCategory,
*,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
) -> Optional[tuple[str, str]]:
"""(contentId, contentTypeId) 또는 None.
★ 상호가 일치하지 않거나 좌표가 멀면 **찾지 못한 것으로 처리한다.**
틀린 업소를 붙이느니 안 붙이는 편이 낫다 — 사장님이 직접 주소를 넣는 경로가 살아 있다.
"""
key = (external_api_config.tour_api_key or "").strip()
if not key:
return None
content_type = CATEGORY_TO_CONTENT_TYPE.get(category)
if not content_type:
return None
items: list[dict] = []
try:
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
for query in _query_candidates(name):
items = await _search(client, key, keyword=query, contentTypeId=content_type)
if items:
if query != name:
LOG.i(f"[tour_lookup] '{name}' 0건 → '{query}' 로 축약해 {len(items)}건")
break
except httpx.HTTPError as ex:
LOG.w(f"[tour_lookup] 조회 실패(계속): {type(ex).__name__}: {ex}")
return None
except RuntimeError as ex:
LOG.w(f"[tour_lookup] 조회 실패(계속): {ex}")
return None
best: Optional[tuple[float, dict]] = None
for item in items:
title = str(item.get("title") or "")
if not _name_matches(name, title):
continue
distance = 0.0
if latitude is not None and longitude is not None:
try:
distance = _distance_m(latitude, longitude, float(item["mapy"]), float(item["mapx"]))
except (KeyError, TypeError, ValueError):
# 좌표가 없는 항목은 거리로 거를 수 없다 — 상호만 맞으면 후순위 후보로 둔다.
distance = float(MAX_DISTANCE_M)
if distance > MAX_DISTANCE_M:
LOG.i(f"[tour_lookup] '{title}' 상호는 맞지만 {distance:.0f}m 떨어져 있어 제외")
continue
if best is None or distance < best[0]:
best = (distance, item)
if best is None:
# ★ 타입을 안 걸고 한 번 더 본다 — 업종을 잘못 등록하면 여기서만 알 수 있다.
# 실측(2026-08-31): 같은 호텔을 '카페' 로 등록했더니 39(음식점)로 조회돼 0건이었다.
# TourAPI 에는 32(숙박)로 있었다. 조용히 '없음' 으로 끝내면 원인을 못 찾는다.
await _warn_if_other_content_type(key, name, content_type)
LOG.i(f"[tour_lookup] '{name}' 일치하는 TourAPI 콘텐츠 없음 (후보 {len(items)}건)")
return None
item = best[1]
LOG.i(
f"[tour_lookup] '{name}' → contentId={item['contentid']} "
f"({item.get('title')}, {best[0]:.0f}m)"
)
return str(item["contentid"]), str(item.get("contenttypeid") or content_type)