o2o-site-AEO/solution/backend/services/external/tour_lookup.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +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)