최상단을 프로젝트 단위로 평평하게 둔다 — 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
132 lines
6.2 KiB
Python
132 lines
6.2 KiB
Python
"""상호·주소 → 네이버 플레이스 id.
|
|
|
|
★ 왜 필요한가
|
|
채널 URL 발견은 Perplexity 가 맡는데, 네이버 플레이스만은 잘 못 찾는다.
|
|
실측(2026-08-27 '도플로'·'버터브루'): 발견 URL 이 전부 야놀자·인스타였고, 필터를
|
|
map.naver.com 까지 넓힌 뒤에도 네이버 쪽은 `pages.map.naver.com/useful-tips` 같은
|
|
안내 페이지가 걸렸다. 검색 언어모델에 맡기기엔 결과가 불안정하고 검색 요금도 든다.
|
|
|
|
그런데 우리는 이미 **이 가게가 누구인지 알고 있다**(동일 업소 검증을 통과한 상호·주소).
|
|
그러면 추측할 이유가 없다 — 통합검색 결과에서 상호가 일치하는 place id 를 직접 고른다.
|
|
|
|
★ 우회하지 않는다. 공개 검색 결과 페이지를 한 번 받아 id 를 읽을 뿐이고,
|
|
막히면 그대로 빈 값을 돌려준다(호출측이 다른 경로로 간다).
|
|
"""
|
|
import re
|
|
from html import unescape
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from common.logger import LOG
|
|
|
|
SEARCH_URL = "https://m.search.naver.com/search.naver"
|
|
REQUEST_TIMEOUT = 20
|
|
|
|
HEADERS = {
|
|
"User-Agent": ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"),
|
|
"Accept-Language": "ko-KR,ko;q=0.9",
|
|
}
|
|
|
|
_PLACE_ID = re.compile(r"(?:place\.naver\.com/[a-z]+/|/place/|\"placeId\"\s*:\s*\"?)(\d{8,12})")
|
|
|
|
# id 주변에서 상호를 찾을 때 훑는 범위.
|
|
#
|
|
# ★ `"name":"…"` JSON 필드를 뽑아 비교하면 안 된다. 그 자리에 실제로 들어 있는 것은
|
|
# 리뷰 키워드("주차하기 편해요")나 블로거 닉네임이고, 상호는 다른 형태로 박혀 있다.
|
|
# 그래서 **정규화한 원문 조각에 상호가 들어 있는지**로 판정한다 — 마크업 모양이 바뀌어도 버틴다.
|
|
_CONTEXT_BEFORE = 600
|
|
_CONTEXT_AFTER = 300
|
|
|
|
|
|
|
|
def _normalize(text: str) -> str:
|
|
"""상호 비교용 정규화. 네이버는 '스테이,머뭄'처럼 구두점을 넣어 표기한다.
|
|
|
|
★ `&` 도 지운다. 검색 결과 원문에는 `&` 로 실려 오기 때문에, 엔티티를 풀어도
|
|
`&` 가 남으면 지역검색이 준 상호(`누에베 풀빌라&리조트`)와 원문 조각의 표기가
|
|
어긋난다. 실측(2026-08-28): 이 한 글자 때문에 place id 를 못 찾아 사장님이
|
|
네이버 지도 주소를 손으로 붙여넣어야 했다 — 10건 중 1건.
|
|
"""
|
|
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
|
|
|
|
|
|
async def _fetch_search_html(query: str) -> Optional[str]:
|
|
"""통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client:
|
|
res = await client.get(SEARCH_URL, params={"query": query, "where": "m"}, headers=HEADERS)
|
|
if res.status_code != 200:
|
|
LOG.w(f"[naver_lookup] 검색 실패 HTTP {res.status_code} — query={query}")
|
|
return None
|
|
return res.text
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[naver_lookup] 검색 실패: {ex}")
|
|
return None
|
|
|
|
|
|
def _match_in_html(html: str, name: str) -> Optional[str]:
|
|
"""검색 결과 원문에서 이 상호에 해당하는 place id 를 고른다.
|
|
|
|
★ `"name":"…"` 를 뽑아 비교하지 않는다. 그 자리에 실제로 들어 있는 것은 리뷰 키워드
|
|
("주차하기 편해요")나 블로거 닉네임이라 상호가 아니다. 그래서 **id 주변 원문을
|
|
정규화해 상호가 들어 있는지**로 판정한다 — 마크업이 바뀌어도 버틴다.
|
|
"""
|
|
target = _normalize(name)
|
|
if not target:
|
|
return None
|
|
for match in _PLACE_ID.finditer(html):
|
|
# ★ 엔티티를 먼저 푼다. 원문에는 상호가 `누에베 풀빌라&리조트` 처럼 인코딩돼 있어,
|
|
# 그대로 정규화하면 `amp` 라는 없는 글자가 상호 한가운데 남는다.
|
|
# 조각(≈900자)에만 적용한다 — 1.3MB 원문 전체를 후보마다 푸는 것은 낭비다.
|
|
window = _normalize(
|
|
unescape(html[max(0, match.start() - _CONTEXT_BEFORE): match.start() + _CONTEXT_AFTER])
|
|
)
|
|
if target in window:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
async def find_place_ids(query: str, names: list[str]) -> dict[str, str]:
|
|
"""후보 상호들에 대해 {상호: place_id} 를 채운다. 못 찾은 상호는 빠진다.
|
|
|
|
★ 왜 후보 목록에 id 를 실어야 하나: 사장님이 후보를 고르는 순간 네이버 플레이스 id 가
|
|
확정되면, 나중에 수집 단계에서 상호를 다시 맞춰 볼 필요가 없다. 이름 맞추기는
|
|
동명 업소·지점명 표기 차이에서 틀리고, 틀리면 남의 가게를 긁는다.
|
|
|
|
검색은 **한 번만** 한다 — 후보 5건에 5번 요청하면 네이버가 막는다(429).
|
|
"""
|
|
html = await _fetch_search_html(query)
|
|
if not html:
|
|
return {}
|
|
found: dict[str, str] = {}
|
|
for name in names:
|
|
place_id = _match_in_html(html, name)
|
|
if place_id:
|
|
found[name] = place_id
|
|
return found
|
|
|
|
|
|
async def find_place_id(name: str, address: Optional[str] = None) -> Optional[str]:
|
|
"""상호(+주소)로 네이버 플레이스 id 를 찾는다. 확신이 없으면 None.
|
|
|
|
★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를
|
|
이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다.
|
|
"""
|
|
query = " ".join(x for x in (name, (address or "").split()[0] if address else "") if x)
|
|
html = await _fetch_search_html(query)
|
|
if not html:
|
|
return None
|
|
place_id = _match_in_html(html, name)
|
|
if place_id:
|
|
LOG.i(f"[naver_lookup] '{name}' → place {place_id}")
|
|
return place_id
|
|
LOG.w(f"[naver_lookup] '{name}' 상호가 일치하는 후보를 찾지 못했다 — 자동 등록하지 않는다")
|
|
return None
|
|
|
|
|
|
def place_url(place_id: str) -> str:
|
|
"""수집 어댑터가 그대로 처리할 수 있는 정규 주소."""
|
|
return f"https://m.place.naver.com/place/{place_id}/home"
|