Compare commits
131 Commits
GEO_NaverE
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79962b93e2 | |||
| 5e2def200b | |||
| a74f7918c6 | |||
| b7b8cb856c | |||
| 362c76d6c9 | |||
| 951e451ef8 | |||
| c366361513 | |||
| 0f58e1485a | |||
| 36dbe26c91 | |||
| be3e43162a | |||
| 324a329b9e | |||
| bbcdf78c62 | |||
| f500210cd8 | |||
| 1300652b09 | |||
| d32df10cf7 | |||
| 95350cfdbf | |||
| 5b54daac10 | |||
| ed0137c9da | |||
| 27426dd51c | |||
| acd5383b0f | |||
| df1556f2b1 | |||
| 627fb1e141 | |||
| a553e41197 | |||
| 09d07ec0dd | |||
| 23c9dd6fcd | |||
| 81a61b50f5 | |||
| 81ea676546 | |||
| adb3460c37 | |||
| cd771719df | |||
| 47da2f29b3 | |||
| 59df616a7b | |||
| 4dd0c604ee | |||
| b1a34ba58d | |||
| 16b17bc91c | |||
| 94ae4a825a | |||
| 4513fae23e | |||
| c26bfb6951 | |||
| 00e318d639 | |||
| 5952f0db62 | |||
| d061a0e6f8 | |||
| 2252588bf2 | |||
| 24dc9345de | |||
| 8d6e6d7cd0 | |||
| b386982f34 | |||
| d358ff4b93 | |||
| 05dd9a3ce7 | |||
| 2f674352b0 | |||
| 4260e20a70 | |||
| 07c53d30bf | |||
| 09e8a7c881 | |||
| b38cff0c0f | |||
| 6badd5c9f0 | |||
| 176a77a231 | |||
| 921c85df25 | |||
| 13cc8cc830 | |||
| 114a6b47d3 | |||
| d77f2aa14c | |||
| b78486b845 | |||
| 872d00f3c4 | |||
| 46171b4f24 | |||
| 1f26bc7065 | |||
| 29af37f158 | |||
| dbae2d4f35 | |||
| 33d27980c4 | |||
| b4085a0e0f | |||
| b76144746b | |||
| 079c93a62a | |||
| 4cd756108d | |||
| 217d0853bc | |||
| a6ddeccdff | |||
| 820a2e9e35 | |||
| f2087aad5e | |||
| 0f7d22750f | |||
| 3f47d5ecd2 | |||
| 9773bc0496 | |||
| f0c4d5f413 | |||
| 6b9e01d876 | |||
| a1a416ca4a | |||
| 7425cb409e | |||
| 93bd5435ac | |||
| 3b4c5c98bc | |||
| 2824dedd42 | |||
| dd3ad86715 | |||
| a093921b62 | |||
| 01098835e9 | |||
| 67a1db3cb9 | |||
| afd41b425a | |||
| bdc69a80f2 | |||
| ff8d3653af | |||
| ab6a6b74e9 | |||
| 0da9b92423 | |||
|
|
33b7b94300 | ||
|
|
8a09af6599 | ||
| 8920e9b9d0 | |||
| 0500ab3a3f | |||
| 3342981bd2 | |||
| a517439c7d | |||
| c8dc68536e | |||
| ecafa19d00 | |||
| 4221dad741 | |||
| 0e0f2cf038 | |||
|
|
b2c8bb033e | ||
| c07e2bddf4 | |||
| 4184cbe665 | |||
| 0c2b915973 | |||
| 69032a72c3 | |||
| ab158be16f | |||
| 29c1a1f462 | |||
| 691b70d599 | |||
| 548ec5c0c1 | |||
| 4216a05fca | |||
| 8a2964ebf7 | |||
| 6760b38fec | |||
| 6fbd0904fa | |||
| ae4953cf89 | |||
| ea4947bb0a | |||
| 9d4feebb86 | |||
| 1dd2dc773e | |||
| 8dba691274 | |||
| 2824701030 | |||
| 46e4d091a4 | |||
| 0f3861c1e4 | |||
| 38bb4be9e5 | |||
| d80877fe75 | |||
| 703f831273 | |||
| 18e86a0d12 | |||
| b86a055278 | |||
| a61d9a278c | |||
| ea1daae576 | |||
| a5b8701f8b | |||
| 411a4e7d55 |
137
.env.example
137
.env.example
@ -31,29 +31,73 @@ PERPLEXITY_API_KEY=
|
|||||||
COLLECT_USE_PERPLEXITY=0
|
COLLECT_USE_PERPLEXITY=0
|
||||||
NAVER_CLIENT_ID=
|
NAVER_CLIENT_ID=
|
||||||
NAVER_CLIENT_SECRET=
|
NAVER_CLIENT_SECRET=
|
||||||
# 네이버 서치어드바이저 소유확인 토큰 = 준 파일명에서 `.html` 을 뺀 값(예: naver1234abcd).
|
|
||||||
# ★ 네이버는 DNS TXT 를 안 받는다 — 구글처럼 DNS 로 끝낼 수 없다.
|
|
||||||
# ★ **실제로 내주는 곳은 `nginx/site.conf`** 다(주석 처리된 블록을 풀어 쓴다).
|
|
||||||
# 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것" — **두 곳이다.**
|
|
||||||
# nginx 가 env 를 못 읽고 site.conf 는 .example 만 커밋되기 때문이고, 어긋나면 아래 점검이
|
|
||||||
# 잡는다. 그게 두 곳을 감수하는 근거다(geo/README.md '제약').
|
|
||||||
# ★ 프론트 재빌드는 필요 없다 — 번들에 안 들어간다. nginx reload 로 끝난다.
|
|
||||||
# ★ 등록 안 하면 사이트맵 제출·수집 요청·색인 진단을 아예 쓸 수 없다(docs/DEPLOY.md 2-2단계).
|
|
||||||
# 확인(레포 루트에서): solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py
|
|
||||||
NAVER_SITE_VERIFICATION=
|
|
||||||
|
|
||||||
# ★ 알리기(IndexNow 통보)를 geo 가 맡는다 — 백엔드 쪽이 고장 나 0건이기 때문이다.
|
|
||||||
# ⚠️ **담당이 두 곳이 되면 안 된다.** 백엔드 indexnow 를 고치는 날 여기를 0 으로 끈다.
|
|
||||||
# (그대로 두면 같은 URL 이 두 번 나가고 429 대상이 된다)
|
|
||||||
# 실행: geo/scripts/postflight.py <slug> · 자동: geo/scripts/watch.py
|
|
||||||
GEO_NOTIFY_ENABLED=1
|
|
||||||
# geo 가 "무엇을 이미 알렸나" 를 두는 자리. 비우면 geo/state/ 다(git 에 안 올라간다).
|
|
||||||
GEO_STATE_DIR=
|
|
||||||
# 미발급. 없으면 네이버 지역검색을 쓴다
|
# 미발급. 없으면 네이버 지역검색을 쓴다
|
||||||
KAKAO_REST_API_KEY=
|
KAKAO_REST_API_KEY=
|
||||||
GEMINI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
||||||
TOUR_API_KEY=
|
TOUR_API_KEY=
|
||||||
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
|
||||||
|
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
|
||||||
|
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
||||||
|
SUNO_API_KEY=
|
||||||
|
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
||||||
|
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100).
|
||||||
|
# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다.
|
||||||
|
# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100),
|
||||||
|
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
|
||||||
|
SITE_ONTOLOGY_URL=
|
||||||
|
|
||||||
|
# 프리렌더가 절대 굽지 않는 슬러그(쉼표 구분). 손으로 만든 목업(/s/stay·stay2·stay3·stay4·stay5)
|
||||||
|
# 이름과 같은 슬러그로 실제 발행이 생기면 그 payload 로 목업을 덮어 구워버린다 — 비우지 않는다.
|
||||||
|
PRERENDER_PROTECTED_SLUGS=stay,stay2,stay3,stay4,stay5
|
||||||
|
|
||||||
|
# ── SNS 게재(스레드) ────────────────────────────────────────────────
|
||||||
|
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
|
||||||
|
# **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다).
|
||||||
|
#
|
||||||
|
# ★ SOCIAL_TOKEN_SECRET 이 없으면 계정 연결 자체를 막는다 — 위임받은 토큰을
|
||||||
|
# 평문으로 보관하는 길을 열지 않는다. 우리 API 키와 성격이 다르다:
|
||||||
|
# API 키는 우리 돈이 나가고, 이 토큰은 **사장님 이름으로 글이 나간다.**
|
||||||
|
SOCIAL_TOKEN_SECRET=
|
||||||
|
# ★ 실제 게시는 이 값이 '1' 일 때만 열린다. 플랫폼 계약과 해지 안내 페이지 정책
|
||||||
|
# (DECISIONS 1-4)을 확인하기 전에는 초안·승인까지만 돌린다 — 게시는 되돌릴 수 없다.
|
||||||
|
SOCIAL_POSTING_ENABLED=0
|
||||||
|
# 승인 요청의 수명. 지나면 EXPIRED 로 내려가고 화면에 '만료됨 · 다시 보내기' 로 남는다.
|
||||||
|
SOCIAL_APPROVAL_HOURS=24
|
||||||
|
# 승인 화면이 열리는 주소(빌더 SPA). 알림톡 버튼이 이 주소로 간다.
|
||||||
|
SOCIAL_APP_ORIGIN=
|
||||||
|
THREADS_APP_ID=
|
||||||
|
THREADS_APP_SECRET=
|
||||||
|
THREADS_REDIRECT_URI=
|
||||||
|
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
|
||||||
|
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
|
||||||
|
ALIMTALK_API_KEY=
|
||||||
|
ALIMTALK_API_SECRET=
|
||||||
|
ALIMTALK_PROFILE_ID=
|
||||||
|
ALIMTALK_SENDER=
|
||||||
|
ALIMTALK_TEMPLATE_CODE=
|
||||||
|
|
||||||
|
# ── 사장님 에이전트 · 카카오톡 채널 연결 ──────────────────────────────
|
||||||
|
# 사장님이 카톡으로 사이트를 고치려면, 채널 발화자(채널 단위 익명 키)를 우리 계정에
|
||||||
|
# 묶어야 한다. 빌더에서 코드를 받아 채널에 한 번 입력하는 절차다.
|
||||||
|
# ★ 이 값이 비면 연결 화면이 아예 안 뜬다 — 어디에 코드를 칠지 말해 줄 수 없는데
|
||||||
|
# 코드만 발급하면 사장님에게는 고장난 화면이다.
|
||||||
|
# 사장님 대화창(에이전트). 1=사용, 0=감춤.
|
||||||
|
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
|
||||||
|
# "눌러도 안 되는 입구" 가 생기지 않는다.
|
||||||
|
AGENT_CHAT_ENABLED=1
|
||||||
|
# 카카오톡 채널 웹훅(오픈빌더 스킬 서버). ★ 오픈빌더는 서명을 주지 않는다 —
|
||||||
|
# URL 만 알면 누구나 때릴 수 있고 발화자 id 를 위조하면 그 사장님 행세를 한다.
|
||||||
|
# 비우면 웹훅 엔드포인트가 404 다(반쯤 열린 상태를 만들지 않는다).
|
||||||
|
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
KAKAO_WEBHOOK_SECRET=
|
||||||
|
# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다.
|
||||||
|
KAKAO_BOT_ID=
|
||||||
|
KAKAO_CHANNEL_PUBLIC_ID=
|
||||||
|
KAKAO_LINK_CODE_TTL_MIN=10
|
||||||
|
KAKAO_LINK_MAX_ATTEMPTS=5
|
||||||
|
|
||||||
|
|
||||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||||
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
||||||
@ -85,6 +129,22 @@ PUBLIC_WEB_BASE_URL=http://localhost
|
|||||||
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
||||||
# 비우면 색인 통보를 건너뛴다(발행은 정상)
|
# 비우면 색인 통보를 건너뛴다(발행은 정상)
|
||||||
INDEXNOW_KEY=
|
INDEXNOW_KEY=
|
||||||
|
|
||||||
|
# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md).
|
||||||
|
GSC_ENABLED=0
|
||||||
|
GSC_PROPERTY_URL=
|
||||||
|
GSC_CREDENTIALS_FILE=
|
||||||
|
GSC_CREDENTIALS_HOST_FILE=
|
||||||
|
GSC_ALERT_DAYS=7
|
||||||
|
GSC_ALERT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# 장애 알림(잡 dead-letter·발행 업무 실패·부분 실패·잡 큐 정체) — Teams Workflows 수신 webhook.
|
||||||
|
# GSC_ALERT_WEBHOOK_URL 과 다른 값이다(그건 색인 감시 전용) — docs/ALERTS.md.
|
||||||
|
# 비우면 알림은 DB(alert_outbox)에 쌓이기만 하고 안 나간다. 서버 동작에는 영향 없다.
|
||||||
|
TEAMS_WEBHOOK_URL=
|
||||||
|
# 재시도마다 중복 스팸을 막는 창(분). 기본 60분 — 같은 사유가 이 시간 안에 또 터지면 다시 안 보낸다.
|
||||||
|
ALERT_DEDUPE_WINDOW_MIN=60
|
||||||
|
|
||||||
# 비우면 로컬 발행만 한다
|
# 비우면 로컬 발행만 한다
|
||||||
AZURE_STORAGE_CONNECTION_STRING=
|
AZURE_STORAGE_CONNECTION_STRING=
|
||||||
AZURE_STORAGE_CONTAINER=
|
AZURE_STORAGE_CONTAINER=
|
||||||
@ -101,6 +161,43 @@ AZURE_STORAGE_PREFIX=
|
|||||||
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
|
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
|
||||||
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
|
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
|
||||||
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
|
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
|
||||||
# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site
|
# ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site,
|
||||||
|
# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는
|
||||||
|
# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다).
|
||||||
AUTO_LOGIN_ID=
|
AUTO_LOGIN_ID=
|
||||||
AUTO_LOGIN_PW=
|
AUTO_LOGIN_PW=
|
||||||
|
|
||||||
|
# ── 메일 발송 (예약 요청 알림) ────────────────────────────────────────────
|
||||||
|
# 비우면 메일을 보내지 않는다. 서버는 그대로 뜨고, 예약 요청 폼은 "전화로 문의" 로 답한다.
|
||||||
|
# ★ 1순위는 회사 공용 Azure Communication Services 다(negodata 와 같은 리소스).
|
||||||
|
# 발신 도메인의 SPF·DKIM 을 그쪽이 관리하므로 메일서버를 새로 세울 필요가 없다.
|
||||||
|
ACS_EMAIL_ENDPOINT=
|
||||||
|
ACS_EMAIL_ACCESSKEY=
|
||||||
|
ACS_EMAIL_SENDER=
|
||||||
|
|
||||||
|
# ACS 를 못 쓰는 환경의 폴백. 이쪽을 쓰면 SPF·DKIM 을 직접 걸어야 한다.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
SMTP_FROM_NAME=Web4AI
|
||||||
|
# starttls(587) · ssl(465) · plain. 비우면 포트로 고른다.
|
||||||
|
SMTP_TLS=
|
||||||
|
|
||||||
|
# SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키.
|
||||||
|
# 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다.
|
||||||
|
SOCIAL_TOKEN_SECRET=
|
||||||
|
THREADS_APP_ID=
|
||||||
|
THREADS_APP_SECRET=
|
||||||
|
THREADS_REDIRECT_URI=https://web4ai.o2osolution.ai/v1/social/oauth/callback
|
||||||
|
SOCIAL_APP_ORIGIN=https://web4ai.o2osolution.ai
|
||||||
|
SOCIAL_APPROVAL_HOURS=24
|
||||||
|
# 앱 심사·테스트 계정 게시·해지 안내 페이지 정책 검증 후 활성화.
|
||||||
|
SOCIAL_POSTING_ENABLED=0
|
||||||
|
# 대행사 선택 전 비워 둔다. 현재 어댑터는 SOLAPI 계약이며 교체는 external/alimtalk.py만.
|
||||||
|
ALIMTALK_API_KEY=
|
||||||
|
ALIMTALK_API_SECRET=
|
||||||
|
ALIMTALK_PROFILE_ID=
|
||||||
|
ALIMTALK_SENDER=
|
||||||
|
ALIMTALK_TEMPLATE_CODE=
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -47,10 +47,6 @@ dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# geo 가 "무엇을 이미 알렸나" 를 기억하는 자리. 재생성물이라 커밋하지 않는다 —
|
|
||||||
# 잃으면 전부 다시 통보할 뿐이고, 그건 규격상 정상(변경 통보)이다.
|
|
||||||
geo/state/
|
|
||||||
|
|
||||||
# ── 에이전트 지침은 커밋한다 ──────────────────────────────────────────────
|
# ── 에이전트 지침은 커밋한다 ──────────────────────────────────────────────
|
||||||
# AGENTS.md / CLAUDE.md 는 팀과 모든 에이전트가 공유하는 규약이라 반드시 커밋한다.
|
# AGENTS.md / CLAUDE.md 는 팀과 모든 에이전트가 공유하는 규약이라 반드시 커밋한다.
|
||||||
# 커밋 안 하면 클론한 사람이 "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
|
# 커밋 안 하면 클론한 사람이 "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
|
||||||
@ -62,3 +58,11 @@ geo/state/
|
|||||||
|
|
||||||
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
|
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# 목업 작업 산출물 — 발행본 원본은 도커 볼륨(out/s)이라 레포에 두지 않는다
|
||||||
|
solution/site/scripts/mockup/backup/
|
||||||
|
solution/site/scripts/mockup/king-stay2/
|
||||||
|
solution/site/scripts/mockup/build6p/
|
||||||
|
solution/site/scripts/mockup/build6p-stay2/
|
||||||
|
solution/site/scripts/mockup/siann6/
|
||||||
|
solution/site/scripts/mockup/_sub*.mjs
|
||||||
|
|||||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
169
.serena/project.yml
Normal file
169
.serena/project.yml
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||||
|
project_name: "o2o-site-AEO"
|
||||||
|
|
||||||
|
# list of language servers to start when using the LSP backend; choose from:
|
||||||
|
# ada al angular ansible bash
|
||||||
|
# bsl clojure cpp cpp_ccls crystal
|
||||||
|
# csharp csharp_omnisharp cue dart deno
|
||||||
|
# elixir elm erlang fortran fsharp
|
||||||
|
# gdscript gleam go groovy haskell
|
||||||
|
# haxe hlsl html java json
|
||||||
|
# julia kotlin latex lean4 lua
|
||||||
|
# luau markdown matlab msl nextflow
|
||||||
|
# nix ocaml pascal perl php
|
||||||
|
# php_phpactor php_phpantom powershell python python_basedpyright
|
||||||
|
# python_jedi python_pyrefly python_ty qml r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# scss solidity svelte swift systemverilog
|
||||||
|
# terraform toml typescript typescript_vts vue
|
||||||
|
# wolfram yaml zig
|
||||||
|
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||||
|
# For the current list, see values of the LanguageServerId enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||||
|
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||||
|
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||||
|
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
|
||||||
|
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some language servers require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language server is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
language_servers:
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
||||||
|
# the command runs in the project root directory and is only executed if the project is trusted
|
||||||
|
# (see trusted_project_path_patterns in the global configuration).
|
||||||
|
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
||||||
|
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
||||||
|
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
||||||
|
# example: activation_command: "npx nx run-many -t build"
|
||||||
|
activation_command:
|
||||||
|
|
||||||
|
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||||
|
# must be a positive number.
|
||||||
|
activation_command_timeout: 180.0
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|
||||||
|
# list of workspace folder paths (LSP backend only).
|
||||||
|
# These folders will be used to build up Serena's symbol index.
|
||||||
|
# Paths must be within the project root and should thus be relative to the project root.
|
||||||
|
# Furthermore, the paths should not be filtered by ignore settings.
|
||||||
|
# Default setting: The entire project root folder (".") is considered.
|
||||||
|
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
||||||
|
# ls_workspace_folders:
|
||||||
|
# - "./subproject1"
|
||||||
|
# - "./subproject2"
|
||||||
|
ls_workspace_folders:
|
||||||
|
- "."
|
||||||
|
|
||||||
|
# list of additional workspace folder paths for cross-package reference support.
|
||||||
|
# Paths can be absolute or relative to the project root.
|
||||||
|
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||||
|
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
||||||
|
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
||||||
|
# Example:
|
||||||
|
# additional_workspace_folders:
|
||||||
|
# - ../sibling-package
|
||||||
|
# - ../shared-lib
|
||||||
|
ls_additional_workspace_folders: []
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
||||||
|
# Example:
|
||||||
|
# ignored_paths:
|
||||||
|
# - "examples/**"
|
||||||
|
# - ".worktrees/**"
|
||||||
|
# - "**/bin/**"
|
||||||
|
# - "**/obj/**"
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||||
|
# for this project.
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
added_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
150
AGENTS.md
150
AGENTS.md
@ -1,5 +1,8 @@
|
|||||||
# AGENTS.md — 이 레포에서 작업하기 전에
|
# AGENTS.md — 이 레포에서 작업하기 전에
|
||||||
|
|
||||||
|
> 2026-09-15 발행 버전 전환: [docs/PUBLISH_VERSION.md](docs/PUBLISH_VERSION.md)가
|
||||||
|
> 아래의 상시 프리렌더·재굽기·자산 주소 교체 절차를 대체한다. 목업은 변경하지 않는다.
|
||||||
|
|
||||||
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
|
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
|
||||||
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
|
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
|
||||||
|
|
||||||
@ -11,9 +14,11 @@
|
|||||||
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
|
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
|
||||||
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
||||||
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
||||||
| **네이버**에서 탐색되게 하려면 (구글과 다르다) | [docs/NAVER_EO.md](docs/NAVER_EO.md) |
|
|
||||||
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
||||||
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
||||||
|
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
|
||||||
|
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
|
||||||
|
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -35,7 +40,48 @@
|
|||||||
→ **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
|
→ **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
|
||||||
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
|
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
|
||||||
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
|
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
|
||||||
|
→ ★ **`stay` 는 프리렌더가 굽지 않는다** (`prerender.ts` `PROTECTED_SLUGS`, 기본 `stay` ·
|
||||||
|
`PRERENDER_PROTECTED_SLUGS` 로 덮어쓴다). "payload 가 없으면 안 굽는다"
|
||||||
|
는 보호가 못 된다 — **payload 가 생기는 순간** 덮인다. 실측(2026-09-15): 누가 빌더에서
|
||||||
|
슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자 프리렌더가 `/s/stay` 를 그 payload 로
|
||||||
|
구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부).
|
||||||
|
그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다.
|
||||||
|
|
||||||
|
- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`).
|
||||||
|
`payload.media[].url` 이 남의 도메인이면 `out/s/<slug>/img/<주소해시>.<확장자>` 로 받아 놓고
|
||||||
|
payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 —
|
||||||
|
수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서
|
||||||
|
그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행
|
||||||
|
사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다.
|
||||||
|
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
|
||||||
|
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
|
||||||
|
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
|
||||||
|
(아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
|
||||||
|
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
|
||||||
|
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
|
||||||
|
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
|
||||||
|
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
|
||||||
|
→ `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
|
||||||
|
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
|
||||||
|
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
|
||||||
|
(2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
|
||||||
|
css js만 안 깨지게 하란 말이야")
|
||||||
|
예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를
|
||||||
|
한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이
|
||||||
|
없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다.
|
||||||
|
지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다
|
||||||
|
(`watch-payloads.mjs` `refreshAssets` → `prerender.ts` `refreshBakedAssets`):
|
||||||
|
→ 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다.
|
||||||
|
내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다.
|
||||||
|
→ **한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행).
|
||||||
|
→ ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고
|
||||||
|
`PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 —
|
||||||
|
그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다").
|
||||||
|
대상은 `--payload-dir` 에 `<슬러그>.json` 이 있는 사이트뿐이다.
|
||||||
|
★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가
|
||||||
|
그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다.
|
||||||
|
둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만
|
||||||
|
**먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다.
|
||||||
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
|
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
|
||||||
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
|
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
|
||||||
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
|
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
|
||||||
@ -51,15 +97,19 @@
|
|||||||
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
|
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
|
||||||
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
||||||
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
|
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
|
||||||
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
|
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을
|
||||||
|
다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목).
|
||||||
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
|
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
|
||||||
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
|
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
|
||||||
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
|
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
|
||||||
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
|
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
|
||||||
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만
|
||||||
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
|
맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다.
|
||||||
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
|
Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만
|
||||||
→ `docker compose restart solution-prerender` 후 `python scripts/republish_all.py`
|
올린다 — 다른 사이트의 블롭은 그대로다.
|
||||||
|
→ 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면
|
||||||
|
`docker compose restart solution-prerender` 후 `python scripts/republish_all.py` 인데,
|
||||||
|
**먼저 묻는다**(발행일이 전부 움직인다).
|
||||||
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
||||||
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
||||||
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
||||||
@ -68,6 +118,13 @@
|
|||||||
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
|
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
|
||||||
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
|
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
|
||||||
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
|
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
|
||||||
|
- **★ `VITE_AUTO_LOGIN_ID`·`PW` 는 운영 진입점(`solution-site`, nginx/Dockerfile)에 절대
|
||||||
|
넘기지 않는다.** 예전엔 `docker-compose.yml` 의 `solution-site` build args 에 이 값이
|
||||||
|
실제로 흘러가고 있었다 — `.env` 에 채운 채로 배포하면 자동 로그인 계정이 사장님이 여는
|
||||||
|
운영 번들에 그대로 구워졌다(누구나 JS 에서 읽을 수 있다). 지금은 그 build arg 자체가
|
||||||
|
없다. `lib/autoSession.ts` 의 `import.meta.env.DEV` 가드가 둘째 안전판이다 — 실수로
|
||||||
|
값이 다시 넘어와도 운영 빌드(`vite build`)에서는 죽은 코드로 접혀 번들에서 빠진다.
|
||||||
|
자동 로그인이 필요하면 `solution-frontend`(`--profile dev`, `vite dev`)만 쓴다.
|
||||||
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
|
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
|
||||||
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
|
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
|
||||||
CDN 을 앞에 세워야 한다. 아니면 비워라.
|
CDN 을 앞에 세워야 한다. 아니면 비워라.
|
||||||
@ -85,6 +142,55 @@
|
|||||||
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
|
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
|
||||||
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
|
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
|
||||||
|
|
||||||
|
- **★ SNS 게재 승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저
|
||||||
|
프리페치가 **사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이
|
||||||
|
올라가고 로그에는 "승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
|
||||||
|
링크는 확인 화면을 열 뿐이고 게시는 그 화면의 POST 다([DECISIONS 8-3](docs/DECISIONS.md)).
|
||||||
|
- **★ SNS 게재는 `sites.domain` 이 확정된 사이트에만 허용한다.** `domain` 이 비면 발행 슬러그가
|
||||||
|
**상호명에서 파생**되고(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다. `SITE_SLUG_LOCKED`
|
||||||
|
는 `domain` 변경만 막으므로 여기엔 안 걸린다 — **이미 올라간 글의 링크는 404 가 되고 그 글은
|
||||||
|
수정할 수 없다.**
|
||||||
|
- **★ ORM 의 `server_default=text("'…'")` 에 쉼표를 딸려 보내지 않는다.** `text("'[]',")` 는
|
||||||
|
`DEFAULT '[]', NOT NULL` 로 나가 **CREATE TABLE 이 통째로 실패**한다. 운영 DB 는 init.sql 로
|
||||||
|
만들어져 안 드러나고, **ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다(실측 2026-09-14).
|
||||||
|
|
||||||
|
## SNS에서 조용히 틀리는 것 (2026-09-14)
|
||||||
|
|
||||||
|
- domain NULL은 임시 주소다. SNS는 PUBLISHED + current_version_id + 확정 domain을 모두 요구한다.
|
||||||
|
- 승인 GET은 프리페치가 연다. 상태 전이는 POST의 nonce 해시 + PENDING CAS로만 한다.
|
||||||
|
- Threads는 X의 offline.access/회전 refresh_token 계약을 쓰지 않는다. 장기 access token을 갱신한다.
|
||||||
|
- 토큰 갱신 저장 실패는 재연결. POSTING 중단·응답 유실은 UNKNOWN이며 자동 재게시 금지.
|
||||||
|
- 초기 SOCIAL_POSTING_ENABLED=0. [SOCIAL.md](docs/SOCIAL.md)의 실제 게시·해지 안내 페이지 전제를 확인한 뒤 연다.
|
||||||
|
|
||||||
|
## 에이전트에서 조용히 틀리는 것 (2026-09-21)
|
||||||
|
|
||||||
|
- **도구가 `crud` 를 직접 부르면 게이트가 통째로 뚫린다** — 업종 스키마 검증·출처 필수·정정본
|
||||||
|
보호가 사라지는데 **아무 증상이 없다**(값은 들어가고 빌드도 성공한다). 도구는 반드시
|
||||||
|
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
|
||||||
|
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
|
||||||
|
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
|
||||||
|
- **★ 카카오 웹훅은 서명이 없다 — 시크릿이 유일한 문이다.** 오픈빌더는 서명을 주지 않아서,
|
||||||
|
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
|
||||||
|
`KAKAO_WEBHOOK_SECRET` 이 비면 엔드포인트가 **404**(401 은 존재를 알린다).
|
||||||
|
- **확인 대기에 만료가 없으면 묵은 발행이 돈다** — 카카오톡은 앞선 답을 되돌려 주지 않아
|
||||||
|
서버가 pending 을 들고 있는다. `pending_expires_at`(3분)을 빼면 한참 뒤의 "네" 한 마디에
|
||||||
|
실행된다([AGENT.md](docs/AGENT.md)).
|
||||||
|
- **바로가기 라벨과 '예' 로 읽는 말이 어긋나면 눌러도 안 먹는다** — 사장님은 버튼이 고장난
|
||||||
|
줄 안다. `channel.py` 의 `CONFIRM_LABEL` 상수를 쓰고 문자열을 손으로 적지 않는다.
|
||||||
|
- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`).
|
||||||
|
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
|
||||||
|
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.
|
||||||
|
카카오 연결 카드는 `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 감춰진다 —
|
||||||
|
웹훅(4단계)이 없어 코드를 보내도 연결이 완성되지 않기 때문이다([AGENT.md](docs/AGENT.md)).
|
||||||
|
- **에이전트 등급을 모델이 정하게 두지 않는다** — 확인이 필요한 행위인지는 `services/agent/tools.py`
|
||||||
|
레지스트리가 못 박는다. 응답 스키마에 그 칸을 만들면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다.
|
||||||
|
- **실행 결과 문구를 LLM 이 쓰게 두지 않는다** — 모델은 **하지 않은 일을 했다고 말할 수 있고**,
|
||||||
|
사장님에게는 그 말이 사실로 보인다. 화면의 "바꿨습니다" 는 코드가 보장하는 문장이어야 한다.
|
||||||
|
- **값을 고친 뒤 재발행 안내를 빠뜨리지 않는다** — fact 는 바뀌어도 사이트는 안 바뀐다.
|
||||||
|
사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
||||||
|
- **코드 소비 경로를 웹훅 서명 검증보다 먼저 열지 않는다** — 누구나 6자리를 대입해 남의
|
||||||
|
계정에 자기 카톡을 붙일 수 있다. 지금 `redeem()` 이 라우터에 없는 이유다([AGENT.md](docs/AGENT.md)).
|
||||||
|
|
||||||
## 코드 규약
|
## 코드 규약
|
||||||
|
|
||||||
- **미결 사항은 코드로 풀지 않는다.** [DECISIONS.md](docs/DECISIONS.md) 1절이 보류한 것은
|
- **미결 사항은 코드로 풀지 않는다.** [DECISIONS.md](docs/DECISIONS.md) 1절이 보류한 것은
|
||||||
@ -101,7 +207,6 @@
|
|||||||
```
|
```
|
||||||
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
||||||
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
||||||
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
|
|
||||||
```
|
```
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
||||||
@ -110,21 +215,6 @@ geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우
|
|||||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
||||||
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
||||||
|
|
||||||
★ **`geo` 도 한 방향이다 — `geo` → `solution/backend`.** `admin` 과 같은 방식으로
|
|
||||||
`solution/backend` 를 PYTHONPATH 로 얹어 쓴다(네이버 API 쿼터 카운터 · `publish_origin`).
|
|
||||||
**`solution` 이 `geo` 를 import 하면 순환이다** — 그래서 라우터를 solution 의 라우터 트리에
|
|
||||||
끼우지 않고 진입점이 마운트한다. 워커 핸들러도 같은 이유로 등록만 진입점에서 한다.
|
|
||||||
|
|
||||||
★ **`geo/naver/checks.py` 는 DB 를 보지 않는다** (세션을 인자로도 받지 않는다).
|
|
||||||
"우리 DB 가 그렇다" 와 "밖에서 그렇게 보인다" 를 한 함수에 섞으면 어긋났을 때 어느 쪽이
|
|
||||||
틀렸는지 말할 수 없고, 그 어긋남을 찾으려고 만든 모듈이 쓸모를 잃는다([geo/README.md](geo/README.md)).
|
|
||||||
|
|
||||||
★ **`geo` 는 `solution/`·`admin/` 의 파일을 고치지 않는다 — import 만 한다.**
|
|
||||||
그래서 원래 저쪽에 있어야 할 것 셋이 자리를 옮겼고, 대가가 남았다
|
|
||||||
([geo/README.md](geo/README.md) '제약' 절). 밟기 쉬운 것 둘:
|
|
||||||
⚠️ **네이버 쿼터 카운터가 둘로 갈렸다**(합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다),
|
|
||||||
⚠️ **소유확인 토큰이 `nginx/site.conf` 와 루트 `.env` 두 곳에 산다**(어긋나면 점검이 잡는다).
|
|
||||||
|
|
||||||
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
||||||
|
|
||||||
| | 포트 | 진입점 | 권한 |
|
| | 포트 | 진입점 | 권한 |
|
||||||
@ -237,7 +327,6 @@ crash 로 굳은 페이지는 content() 가 CDP 응답을 상한 없이 기다
|
|||||||
```
|
```
|
||||||
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
||||||
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
||||||
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
|
|
||||||
```
|
```
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
||||||
@ -246,21 +335,6 @@ geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우
|
|||||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
||||||
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
||||||
|
|
||||||
★ **`geo` 도 한 방향이다 — `geo` → `solution/backend`.** `admin` 과 같은 방식으로
|
|
||||||
`solution/backend` 를 PYTHONPATH 로 얹어 쓴다(네이버 API 쿼터 카운터 · `publish_origin`).
|
|
||||||
**`solution` 이 `geo` 를 import 하면 순환이다** — 그래서 라우터를 solution 의 라우터 트리에
|
|
||||||
끼우지 않고 진입점이 마운트한다. 워커 핸들러도 같은 이유로 등록만 진입점에서 한다.
|
|
||||||
|
|
||||||
★ **`geo/naver/checks.py` 는 DB 를 보지 않는다** (세션을 인자로도 받지 않는다).
|
|
||||||
"우리 DB 가 그렇다" 와 "밖에서 그렇게 보인다" 를 한 함수에 섞으면 어긋났을 때 어느 쪽이
|
|
||||||
틀렸는지 말할 수 없고, 그 어긋남을 찾으려고 만든 모듈이 쓸모를 잃는다([geo/README.md](geo/README.md)).
|
|
||||||
|
|
||||||
★ **`geo` 는 `solution/`·`admin/` 의 파일을 고치지 않는다 — import 만 한다.**
|
|
||||||
그래서 원래 저쪽에 있어야 할 것 셋이 자리를 옮겼고, 대가가 남았다
|
|
||||||
([geo/README.md](geo/README.md) '제약' 절). 밟기 쉬운 것 둘:
|
|
||||||
⚠️ **네이버 쿼터 카운터가 둘로 갈렸다**(합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다),
|
|
||||||
⚠️ **소유확인 토큰이 `nginx/site.conf` 와 루트 `.env` 두 곳에 산다**(어긋나면 점검이 잡는다).
|
|
||||||
|
|
||||||
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
||||||
|
|
||||||
| | 포트 | 진입점 | 권한 |
|
| | 포트 | 진입점 | 권한 |
|
||||||
|
|||||||
@ -47,15 +47,13 @@ solution/ 사장님 — 사이트 만들기·관리
|
|||||||
admin/ 우리 — 전체 사이트 운영
|
admin/ 우리 — 전체 사이트 운영
|
||||||
backend/ 진입점만(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
|
backend/ 진입점만(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
|
||||||
frontend/ 운영 화면. `@` 별칭이 solution/frontend/src 를 가리킨다
|
frontend/ 운영 화면. `@` 별칭이 solution/frontend/src 를 가리킨다
|
||||||
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈.
|
|
||||||
admin 처럼 solution/backend 를 PYTHONPATH 로 얹어 쓴다
|
|
||||||
docs/ 아래 표
|
docs/ 아래 표
|
||||||
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋)
|
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋)
|
||||||
postgres-init/ 스키마 DDL
|
postgres-init/ 스키마 DDL
|
||||||
```
|
```
|
||||||
|
|
||||||
의존 방향은 `admin → solution` · `geo → solution` 두 줄이고 **둘 다 한 방향**이다.
|
의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다.
|
||||||
반대가 생기면 가른 의미가 사라진다. 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
## 문서 지도
|
## 문서 지도
|
||||||
|
|
||||||
@ -68,7 +66,6 @@ postgres-init/ 스키마 DDL
|
|||||||
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
||||||
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |
|
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |
|
||||||
| [docs/COLLECTION_SEO_AEO_FLOW.md](docs/COLLECTION_SEO_AEO_FLOW.md) | 수집→LLM→SEO/AEO 현재 구현 |
|
| [docs/COLLECTION_SEO_AEO_FLOW.md](docs/COLLECTION_SEO_AEO_FLOW.md) | 수집→LLM→SEO/AEO 현재 구현 |
|
||||||
| [docs/NAVER_EO.md](docs/NAVER_EO.md) | **네이버는 구조가 다르다** — 무엇을 목표로 잡나 (조사·설계) |
|
|
||||||
| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 |
|
| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 |
|
||||||
|
|
||||||
## 문서 규칙
|
## 문서 규칙
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import router.v1.fact.fact
|
|||||||
import router.v1.job.job
|
import router.v1.job.job
|
||||||
import router.v1.local.local
|
import router.v1.local.local
|
||||||
import router.v1.place.place
|
import router.v1.place.place
|
||||||
|
import router.v1.site.review_admin
|
||||||
import router.v1.site.site
|
import router.v1.site.site
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -84,3 +85,4 @@ app.include_router(router.v1.fact.fact.router, dependencies=_gate)
|
|||||||
app.include_router(router.v1.job.job.router, dependencies=_gate)
|
app.include_router(router.v1.job.job.router, dependencies=_gate)
|
||||||
app.include_router(router.v1.site.site.router, dependencies=_gate)
|
app.include_router(router.v1.site.site.router, dependencies=_gate)
|
||||||
app.include_router(router.v1.local.local.router, dependencies=_gate)
|
app.include_router(router.v1.local.local.router, dependencies=_gate)
|
||||||
|
app.include_router(router.v1.site.review_admin.router, dependencies=_gate)
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import {Building2, CalendarDays} from 'lucide-react';
|
import {Building2, CalendarDays, MessageSquareQuote} from 'lucide-react';
|
||||||
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
|
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
|
||||||
import {AppShell, type NavItem} from '@/components/layout/AppShell';
|
import {AppShell, type NavItem} from '@/components/layout/AppShell';
|
||||||
import {RequireAuth} from '@/components/layout/RequireAuth';
|
import {RequireAuth} from '@/components/layout/RequireAuth';
|
||||||
import {LocalContentPage} from '@admin/pages/LocalContentPage';
|
import {LocalContentPage} from '@admin/pages/LocalContentPage';
|
||||||
|
import {ReviewModerationPage} from '@admin/pages/ReviewModerationPage';
|
||||||
import {LoginPage} from '@/pages/LoginPage';
|
import {LoginPage} from '@/pages/LoginPage';
|
||||||
import {NotFoundPage} from '@/pages/NotFoundPage';
|
import {NotFoundPage} from '@/pages/NotFoundPage';
|
||||||
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
||||||
@ -16,6 +17,7 @@ import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
|||||||
const ADMIN_NAV: NavItem[] = [
|
const ADMIN_NAV: NavItem[] = [
|
||||||
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
||||||
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
||||||
|
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,6 +43,7 @@ export const router = createBrowserRouter([
|
|||||||
{path: '/places/:placeId', element: <PlaceDetailPage />},
|
{path: '/places/:placeId', element: <PlaceDetailPage />},
|
||||||
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
|
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
|
||||||
{path: '/local-content', element: <LocalContentPage />},
|
{path: '/local-content', element: <LocalContentPage />},
|
||||||
|
{path: '/reviews', element: <ReviewModerationPage />},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
169
admin/frontend/src/pages/ReviewModerationPage.tsx
Normal file
169
admin/frontend/src/pages/ReviewModerationPage.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||||
|
import {RefreshCw, X} from 'lucide-react';
|
||||||
|
import {PageContainer} from '@/components/layout/AppShell';
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent} from '@/components/ui/card';
|
||||||
|
import {customFetch} from '@/api/mutator/custom-fetch';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
|
||||||
|
*
|
||||||
|
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
|
||||||
|
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
|
||||||
|
*/
|
||||||
|
type Review = {
|
||||||
|
review_id: string;
|
||||||
|
place_id: string;
|
||||||
|
place_name: string;
|
||||||
|
body: string;
|
||||||
|
nickname: string;
|
||||||
|
status: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TABS: {value: number; label: string}[] = [
|
||||||
|
{value: 2, label: '게재된 후기'},
|
||||||
|
{value: 3, label: '내린 글'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReviewModerationPage() {
|
||||||
|
const [status, setStatus] = useState(2);
|
||||||
|
const [posts, setPosts] = useState<Review[]>([]);
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await customFetch<{items: Review[]}>({
|
||||||
|
url: `/v1/admin/review/list?status=${status}&limit=200`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
setPosts(res.items ?? []);
|
||||||
|
setPicked(new Set());
|
||||||
|
} catch {
|
||||||
|
toast.error('목록을 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const byPlace = useMemo(() => {
|
||||||
|
const map = new Map<string, Review[]>();
|
||||||
|
for (const post of posts) {
|
||||||
|
const key = post.place_name || post.place_id;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(post);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}, [posts]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const decide = async (approve: boolean) => {
|
||||||
|
const ids = [...picked];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
try {
|
||||||
|
await customFetch({url: '/v1/admin/review/decide', method: 'POST', data: {review_ids: ids, publish: approve}});
|
||||||
|
toast.success(approve ? `${ids.length}건을 다시 올렸습니다.` : `${ids.length}건을 내렸습니다.`);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast.error('처리하지 못했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="이용 후기"
|
||||||
|
description="손님이 남기면 바로 사이트에 올라갑니다. 문제가 있는 글만 여기서 내립니다."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||||
|
<RefreshCw className="size-4" /> 새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.value}
|
||||||
|
size="sm"
|
||||||
|
variant={tab.value === status ? 'primary' : 'outline'}
|
||||||
|
onClick={() => setStatus(tab.value)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 2 && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">{picked.size}건 선택</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void decide(false)} disabled={picked.size === 0}>
|
||||||
|
<X className="size-4" /> 내리기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPicked(new Set(posts.map((post) => post.review_id)))}
|
||||||
|
disabled={posts.length === 0}
|
||||||
|
>
|
||||||
|
전체 선택
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{posts.length === 0 && !loading && (
|
||||||
|
<p className="text-muted-foreground py-10 text-center text-sm">이 상태의 글이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{byPlace.map(([placeName, rows]) => (
|
||||||
|
<section key={placeName}>
|
||||||
|
<h2 className="mb-2 text-sm font-bold">
|
||||||
|
{placeName} <span className="text-muted-foreground font-normal">{rows.length}건</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((post) => (
|
||||||
|
<Card key={post.review_id}>
|
||||||
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
|
{status === 2 && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`review-${post.review_id}`}
|
||||||
|
checked={picked.has(post.review_id)}
|
||||||
|
onChange={() => toggle(post.review_id)}
|
||||||
|
className="mt-1 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="accent">{post.nickname || '손님'}</Badge>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">{post.body.length}자</span>
|
||||||
|
</div>
|
||||||
|
<label htmlFor={`review-${post.review_id}`} className="block text-sm leading-relaxed">
|
||||||
|
{post.body}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
deploy.sh
141
deploy.sh
@ -1,127 +1,40 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 배포 — 코드를 당기고, 지정한 서비스만 다시 빌드해 갈아끼운다.
|
# 서버 변경과 꺼진 admin을 보존한다. 렌더러 배포도 기존 사이트를 재굽지 않는다.
|
||||||
#
|
|
||||||
# ./deploy.sh 전체
|
|
||||||
# ./deploy.sh solution-backend 그 서비스만
|
|
||||||
# ./deploy.sh solution-backend solution-worker 여럿
|
|
||||||
#
|
|
||||||
# 서비스명 대신 컨테이너명(o2o-web4ai-solution-backend)으로 불러도 받는다.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
PREFIX=o2o-web4ai
|
|
||||||
# api·worker·api-admin 은 이미지 한 벌(o2o-web4ai-backend)을 나눠 쓴다.
|
|
||||||
BACKEND_SVCS=(solution-backend solution-worker admin-backend)
|
|
||||||
BRANCH=${DEPLOY_BRANCH:-main}
|
|
||||||
|
|
||||||
PULL=1
|
PULL=1
|
||||||
ONLY=0
|
ONLY=0
|
||||||
TARGETS=()
|
TARGETS=()
|
||||||
|
for arg in "$@"; do
|
||||||
usage() {
|
case "$arg" in
|
||||||
cat <<'USAGE'
|
|
||||||
사용법: ./deploy.sh [옵션] [서비스...]
|
|
||||||
|
|
||||||
옵션
|
|
||||||
--no-pull 코드를 당기지 않는다(디스크에 있는 코드 그대로 빌드)
|
|
||||||
--only 백엔드 형제 서비스를 함께 갈아끼우지 않는다 (아래 ★ 참고)
|
|
||||||
-h, --help
|
|
||||||
|
|
||||||
환경변수
|
|
||||||
DEPLOY_BRANCH 기본 main. 다른 브랜치를 배포할 때만 쓴다
|
|
||||||
|
|
||||||
서비스: solution-backend · solution-worker · solution-prerender · solution-site
|
|
||||||
admin-backend · admin-frontend (프로필 admin, 기본 기동에서 빠져 있다)
|
|
||||||
solution-frontend (프로필 dev, 로컬 HMR 전용 — 운영에서 띄우지 않는다)
|
|
||||||
o2o-web4ai-solution-backend 처럼 컨테이너명으로 적어도 된다
|
|
||||||
|
|
||||||
★ solution-backend·solution-worker·admin-backend 는 이미지가 한 벌이다. 하나를 빌드하면 나머지도 새 이미지로
|
|
||||||
갈아끼워야 한다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데, 셋 다 "살아 있음" 이라
|
|
||||||
화면상으로는 배포가 끝난 것처럼 보인다. --only 는 그걸 알고 건너뛸 때만 쓴다.
|
|
||||||
USAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--no-pull) PULL=0 ;;
|
--no-pull) PULL=0 ;;
|
||||||
--only) ONLY=1 ;;
|
--only) ONLY=1 ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) echo "사용법: ./deploy.sh [--no-pull] [--only] [서비스...]"; exit 0 ;;
|
||||||
-*) echo "모르는 옵션: $1" >&2; usage >&2; exit 2 ;;
|
-*) echo "모르는 옵션: $arg" >&2; exit 2 ;;
|
||||||
*) TARGETS+=("${1#"$PREFIX"-}") ;; # 컨테이너명으로 불러도 받는다
|
*) TARGETS+=("${arg#o2o-web4ai-}") ;;
|
||||||
esac
|
esac
|
||||||
shift
|
|
||||||
done
|
done
|
||||||
|
|
||||||
ALL_SVCS=$(docker compose config --services)
|
|
||||||
for t in ${TARGETS+"${TARGETS[@]}"}; do
|
|
||||||
grep -qx "$t" <<<"$ALL_SVCS" || {
|
|
||||||
echo "그런 서비스가 없다: $t" >&2
|
|
||||||
echo "있는 것: $(tr '\n' ' ' <<<"$ALL_SVCS")" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── 코드 ────────────────────────────────────────────────────────────
|
|
||||||
# 배포 서버의 작업트리는 main 의 **사본**이지 작업 공간이 아니다. 그래서 머지(pull)가 아니라
|
|
||||||
# origin/$BRANCH 로 하드 리셋한다 — 밖에서 force-push 가 나도 --ff-only 로 막히지 않고,
|
|
||||||
# 서버에서 누가 손댄 흔적이 다음 배포까지 살아남지 않는다.
|
|
||||||
#
|
|
||||||
# ★ fetch 가 실패하면 **리셋하지 않는다.** 이 서버엔 gitea 자격증명이 없어 fetch 가 죽는데,
|
|
||||||
# 그 상태의 origin/$BRANCH 는 마지막으로 fetch 된 낡은 ref 다.
|
|
||||||
# 실측(2026-09-01 킹서버): HEAD=9b4fe40 인데 origin/main=4871e50 — 믿고 리셋하면 한 커밋
|
|
||||||
# 롤백된다. 그러고도 빌드는 성공하고 컨테이너는 뜬다. 조용히 틀리는 종류다.
|
|
||||||
#
|
|
||||||
# ★ git clean 은 하지 않는다. .env 와 nginx/site.conf 는 추적되지 않는 파일이고 서버마다
|
|
||||||
# 다르다 — reset --hard 는 이 둘을 건드리지 않지만 clean 은 지운다.
|
|
||||||
if [ "$PULL" = 1 ]; then
|
if [ "$PULL" = 1 ]; then
|
||||||
echo "▶ git fetch origin $BRANCH"
|
git diff --quiet && git diff --cached --quiet || { echo "서버 변경을 먼저 정리하세요" >&2; exit 1; }
|
||||||
if git fetch --prune origin "$BRANCH" 2>&1; then
|
git pull --ff-only origin "${DEPLOY_BRANCH:-main}"
|
||||||
DIRTY=$(git status --porcelain)
|
|
||||||
if [ -n "$DIRTY" ]; then
|
|
||||||
echo " ! 작업트리 변경을 버린다:"
|
|
||||||
sed 's/^/ /' <<<"$DIRTY"
|
|
||||||
fi
|
|
||||||
echo "▶ git reset --hard origin/$BRANCH"
|
|
||||||
git reset --hard "origin/$BRANCH"
|
|
||||||
else
|
|
||||||
echo " ! fetch 실패 — 리셋을 건너뛰고 디스크에 있는 코드 그대로 간다." >&2
|
|
||||||
echo " origin/$BRANCH 가 낡았을 수 있어 그걸로 리셋하면 배포가 조용히 롤백된다." >&2
|
|
||||||
echo " (밖에서 밀어넣었으면 이대로 두면 되고, 자동화하려면 gitea 배포키를 건다)" >&2
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
echo "▶ 지금 코드: $(git log --oneline -1)"
|
[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(solution-backend solution-worker solution-site)
|
||||||
|
RUNNING=$(docker compose ps --services --status running)
|
||||||
# ── 대상 정하기 ─────────────────────────────────────────────────────
|
if [ "$ONLY" = 0 ]; then
|
||||||
if [ ${#TARGETS[@]} -eq 0 ]; then
|
case " ${TARGETS[*]} " in
|
||||||
SVCS=() # 빈 인자 = compose 가 전부로 해석한다
|
*" solution-backend "*|*" solution-worker "*)
|
||||||
echo "▶ 대상: 전체"
|
TARGETS+=(solution-backend solution-worker)
|
||||||
else
|
if grep -qx admin-backend <<<"$RUNNING"; then TARGETS+=(admin-backend); fi ;;
|
||||||
SVCS=("${TARGETS[@]}")
|
esac
|
||||||
# 백엔드 하나를 건드리면 같은 이미지를 쓰는 형제도 함께 갈아끼운다.
|
|
||||||
if [ "$ONLY" = 0 ]; then
|
|
||||||
for t in "${TARGETS[@]}"; do
|
|
||||||
for b in "${BACKEND_SVCS[@]}"; do [ "$t" = "$b" ] || continue
|
|
||||||
for sib in "${BACKEND_SVCS[@]}"; do
|
|
||||||
printf '%s\n' "${SVCS[@]}" | grep -qx "$sib" || {
|
|
||||||
SVCS+=("$sib")
|
|
||||||
echo " + $sib — 같은 이미지를 쓴다(옛 코드로 남지 않게 함께 간다)"
|
|
||||||
}
|
|
||||||
done
|
|
||||||
done
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
echo "▶ 대상: ${SVCS[*]}"
|
|
||||||
fi
|
fi
|
||||||
|
SERVICES=()
|
||||||
# ── 빌드 · 교체 ─────────────────────────────────────────────────────
|
for target in "${TARGETS[@]}"; do
|
||||||
# build 절이 없는 서비스(node:24-alpine · nginx:alpine)는 build 가 조용히 건너뛴다.
|
case " ${SERVICES[*]-} " in *" $target "*) ;; *) SERVICES+=("$target");; esac
|
||||||
echo "▶ build"
|
done
|
||||||
docker compose build ${SVCS+"${SVCS[@]}"}
|
# site의 렌더러도 worker 이미지에 들어간다.
|
||||||
|
case " ${SERVICES[*]} " in
|
||||||
echo "▶ up -d"
|
*" solution-site "*) case " ${SERVICES[*]} " in *" solution-worker "*) ;; *) SERVICES+=(solution-worker);; esac ;;
|
||||||
docker compose up -d --force-recreate ${SVCS+"${SVCS[@]}"}
|
esac
|
||||||
|
docker compose build "${SERVICES[@]}"
|
||||||
echo
|
docker compose up -d --no-deps --force-recreate "${SERVICES[@]}"
|
||||||
docker compose ps
|
docker compose ps
|
||||||
echo
|
|
||||||
echo "로그: ./log.sh"
|
|
||||||
|
|||||||
13
docker-compose.search-console.yml
Normal file
13
docker-compose.search-console.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 선택 연동. 키 파일은 저장소 밖에 두고 기존 API 스케줄러에만 읽기 전용으로 전달한다.
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d solution-backend
|
||||||
|
services:
|
||||||
|
solution-backend:
|
||||||
|
environment:
|
||||||
|
GSC_CREDENTIALS_FILE: /run/secrets/search-console.json
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${GSC_CREDENTIALS_HOST_FILE:?Search Console 키 파일 절대경로 필요}
|
||||||
|
target: /run/secrets/search-console.json
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
@ -28,8 +28,12 @@ x-common-env: &common-env
|
|||||||
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
||||||
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
||||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||||
SITE_PAYLOAD_DIR: /app/out/payloads
|
# ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치
|
||||||
SITE_OUTPUT_DIR: /app/out/sites
|
# 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그
|
||||||
|
# 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리
|
||||||
|
# (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다.
|
||||||
|
SITE_PAYLOAD_DIR: /app/solution/site/payloads
|
||||||
|
SITE_OUTPUT_DIR: /app/solution/site/out
|
||||||
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
||||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||||
|
|
||||||
@ -47,11 +51,24 @@ services:
|
|||||||
<<: *common-env
|
<<: *common-env
|
||||||
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
||||||
SCHEDULER_ENABLED: "1"
|
SCHEDULER_ENABLED: "1"
|
||||||
|
# ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한
|
||||||
|
# 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 —
|
||||||
|
# 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다.
|
||||||
|
# 실측(2026-09-18): 이 줄이 없어서 GSC_ENABLED=1 인데도 10분마다
|
||||||
|
# ValueError(GSC_CONFIG_MISSING) 로 조용히 실패하고 있었다 — 로그엔 BATCH_FAILED 만 남아
|
||||||
|
# 원인이 안 보였다.
|
||||||
|
GSC_CREDENTIALS_FILE: /app/secrets/gsc-credentials.json
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/solution/site/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
|
||||||
|
# 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/solution/site/songs
|
||||||
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
||||||
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
||||||
- ./postgres-init:/app/postgres-init:ro
|
- ./postgres-init:/app/postgres-init:ro
|
||||||
|
# ★ GSC_CREDENTIALS_HOST_FILE 이 비어 있으면 /dev/null 을 마운트한다 — 빈 문자열을 그대로
|
||||||
|
# 쓰면 컴포즈 볼륨 문법이 깨진다. GSC_ENABLED=0 이면 이 파일은 아예 안 읽으므로 무해하다.
|
||||||
|
- ${GSC_CREDENTIALS_HOST_FILE:-/dev/null}:/app/secrets/gsc-credentials.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
|
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@ -61,13 +78,17 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "5" }
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
|
# ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다
|
||||||
|
# (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다.
|
||||||
|
# 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러
|
||||||
|
# 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다.
|
||||||
solution-worker:
|
solution-worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: solution/backend/Dockerfile
|
dockerfile: solution/backend/Dockerfile.worker
|
||||||
image: o2o-web4ai-backend
|
image: o2o-web4ai-worker
|
||||||
container_name: o2o-web4ai-solution-worker
|
container_name: o2o-web4ai-solution-worker
|
||||||
command: ["python", "worker_main.py"]
|
command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@ -76,12 +97,20 @@ services:
|
|||||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
||||||
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
||||||
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
||||||
|
# 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의
|
||||||
|
# job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다.
|
||||||
|
RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180}
|
||||||
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/solution/site/payloads
|
||||||
- site-out:/app/out/sites:ro
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더러가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/solution/site/songs
|
||||||
|
# ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가
|
||||||
|
# 쓰던 자리를 그대로 이어받는다).
|
||||||
|
- site-out:/app/solution/site/out
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
stop_grace_period: 300s
|
stop_grace_period: 300s
|
||||||
@ -118,7 +147,10 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/solution/site/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/solution/site/songs
|
||||||
ports:
|
ports:
|
||||||
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
||||||
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
||||||
@ -131,48 +163,6 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "5" }
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
# 발행 사이트를 굽는다. **굽기만 한다** — 서빙은 solution-site(nginx)가 맡는다.
|
|
||||||
#
|
|
||||||
# ★ 사장님 앱 dev 서버는 여기서 빠졌다. 운영에 `vite dev` 를 띄우면 요청마다 트랜스파일하고
|
|
||||||
# 기동이 npm install 네트워크에 의존하고 /src 원본이 그대로 나간다. 번들은 solution-site
|
|
||||||
# 이미지가 굽는다(nginx/Dockerfile). 로컬에서 HMR 이 필요하면 `--profile dev`.
|
|
||||||
solution-prerender:
|
|
||||||
image: node:24-alpine
|
|
||||||
container_name: o2o-web4ai-solution-prerender
|
|
||||||
working_dir: /app
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
cd /app
|
|
||||||
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
|
|
||||||
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
|
|
||||||
[ -x node_modules/.bin/vite ] || npm install
|
|
||||||
exec node solution/site/scripts/watch-payloads.mjs
|
|
||||||
environment:
|
|
||||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
|
||||||
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
|
|
||||||
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
|
||||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
|
||||||
volumes:
|
|
||||||
- ./package.json:/app/package.json
|
|
||||||
- ./package-lock.json:/app/package-lock.json
|
|
||||||
- ./tsconfig.base.json:/app/tsconfig.base.json
|
|
||||||
- ./solution:/app/solution
|
|
||||||
- ./admin:/app/admin
|
|
||||||
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
|
|
||||||
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
|
|
||||||
- /app/node_modules
|
|
||||||
- /app/solution/site/node_modules
|
|
||||||
- /app/solution/frontend/node_modules
|
|
||||||
- /app/admin/frontend/node_modules
|
|
||||||
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
|
|
||||||
- site-out:/app/solution/site/out
|
|
||||||
restart: unless-stopped
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options: { max-size: "10m", max-file: "5" }
|
|
||||||
|
|
||||||
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
|
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
|
||||||
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
|
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
|
||||||
solution-frontend:
|
solution-frontend:
|
||||||
@ -268,11 +258,10 @@ services:
|
|||||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
|
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
|
||||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||||
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
|
||||||
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
|
||||||
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
|
||||||
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
|
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
|
||||||
|
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
|
||||||
image: o2o-web4ai-solution-site
|
image: o2o-web4ai-solution-site
|
||||||
container_name: o2o-web4ai-solution-site
|
container_name: o2o-web4ai-solution-site
|
||||||
volumes:
|
volumes:
|
||||||
@ -285,7 +274,66 @@ services:
|
|||||||
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
|
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
|
||||||
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
|
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- solution-prerender
|
- solution-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
|
# ── 온톨로지(o2o-site-ontology) — 발행본 메타 키워드·제목 업종어 ──────────────
|
||||||
|
# ★ 자체 DB 가 따로 있다. 호스트 postgres 를 같이 쓰지 않는 이유는 pgvector 확장이 필요해서다.
|
||||||
|
# 우리 DB 에 확장을 걸면 web4ai_db 가 그 확장에 묶인다 — 남의 서비스 사정을 우리 DB 가 떠안는다.
|
||||||
|
ontology-postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: o2o-web4ai-ontology-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ontology
|
||||||
|
POSTGRES_PASSWORD: ontology
|
||||||
|
POSTGRES_DB: ontology
|
||||||
|
volumes:
|
||||||
|
- ontology-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ontology-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: o2o-web4ai-ontology-redis
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ontology:
|
||||||
|
build:
|
||||||
|
context: ./ontology
|
||||||
|
container_name: o2o-web4ai-ontology
|
||||||
|
environment:
|
||||||
|
PORT: "3100"
|
||||||
|
# 컨테이너끼리는 서비스 이름으로 만난다 — 호스트 포트(55432·56379)는 사람이 들여다볼 때만 쓴다.
|
||||||
|
DATABASE_URL: postgres://ontology:ontology@ontology-postgres:5432/ontology
|
||||||
|
REDIS_HOST: ontology-redis
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
# API 키 없이 도는 기본값(로컬 임베딩 + mock LLM). 키를 쓰려면 .env 에서 덮어쓴다.
|
||||||
|
EMBEDDING_PROVIDER: ${ONTOLOGY_EMBEDDING_PROVIDER:-local}
|
||||||
|
EMBEDDING_LOCAL_MODEL: ${ONTOLOGY_EMBEDDING_MODEL:-Xenova/multilingual-e5-small}
|
||||||
|
LLM_PROVIDER: ${ONTOLOGY_LLM_PROVIDER:-mock}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
# 임베딩 모델 캐시. 볼륨이 없으면 컨테이너를 새로 만들 때마다 120MB 를 다시 받는다.
|
||||||
|
- ontology-model:/app/.cache
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3100:3100"
|
||||||
|
depends_on:
|
||||||
|
ontology-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ontology-redis:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
@ -294,3 +342,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
||||||
site-out:
|
site-out:
|
||||||
|
# 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다.
|
||||||
|
ontology-pgdata:
|
||||||
|
ontology-model:
|
||||||
|
|||||||
273
docs/AGENT.md
Normal file
273
docs/AGENT.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
|
||||||
|
|
||||||
|
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
|
||||||
|
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
|
||||||
|
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
|
||||||
|
|
||||||
|
1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다.
|
||||||
|
남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.
|
||||||
|
|
||||||
|
## 화면 스위치
|
||||||
|
|
||||||
|
| 화면 | 여는 조건 | 지금 |
|
||||||
|
|---|---|---|
|
||||||
|
| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=1`(기본) **그리고** LLM 키 | 열림 |
|
||||||
|
| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 채워짐 | 채널 ID 미설정 |
|
||||||
|
|
||||||
|
★ 스위치와 키를 **둘 다** 본다(`runtime.is_configured`). 키만 보면 "잠시 닫아 두기" 를 키를
|
||||||
|
지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서
|
||||||
|
**눌러도 안 되는 입구**가 생긴다.
|
||||||
|
|
||||||
|
★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고,
|
||||||
|
인증이 끝나 2026-09-22 에 다시 열었다. **그때도 코드는 지우지 않고 값만 바꿨다** —
|
||||||
|
닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다.
|
||||||
|
|
||||||
|
★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대
|
||||||
|
판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고,
|
||||||
|
이쪽은 웹훅(4단계)이 없어 아직 연결이 **완성되지 않는다**.
|
||||||
|
|
||||||
|
## 왜 신원 연결이 먼저인가
|
||||||
|
|
||||||
|
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**다. 우리 `user_id` 와 아무 관계가 없다.
|
||||||
|
|
||||||
|
이 레포의 모든 엔드포인트는 `place_crud.get_place(s, owner_user_id, place_id)` 로
|
||||||
|
"없는 것과 남의 것을 똑같이 `PLACE_NOT_FOUND` 로 답하는" 관례를 지킨다. 채널에서 온 발화에는
|
||||||
|
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
||||||
|
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
||||||
|
|
||||||
|
## 절차 — 사장님은 두 번 누른다
|
||||||
|
|
||||||
|
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
||||||
|
2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다
|
||||||
|
|
||||||
|
★ **연결 버튼을 사업장 화면에 두지 않는다.** 연결은 `user` 단위인데 버튼이 사업장 안에 있으면
|
||||||
|
사장님은 업장마다 연결해야 하는 줄 안다(`SocialConnectionCard` 가 같은 이유로 거기 있다).
|
||||||
|
|
||||||
|
★ `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 **카드는 그리되 버튼이 죽는다.** 어디에 코드를 칠지
|
||||||
|
말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면
|
||||||
|
기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).
|
||||||
|
|
||||||
|
## 표 — `owner_kakao_links` (마이그레이션 0021)
|
||||||
|
|
||||||
|
`user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at`
|
||||||
|
|
||||||
|
| 인덱스 | 무엇을 막나 |
|
||||||
|
|---|---|
|
||||||
|
| `uq_kakao_link_user` (PENDING·LINKED) | 한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다 |
|
||||||
|
| `uq_kakao_link_channel_key` (LINKED) | ★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 |
|
||||||
|
| `uq_kakao_link_code` (PENDING) | 코드 한 행 지목 |
|
||||||
|
|
||||||
|
**코드는 평문으로 저장하지 않는다**(`code_sha`). 사장님이 손으로 치는 짧은 값이라, 평문이면
|
||||||
|
DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 **화면에 한 번 뜨고 다시 볼 수 없다** —
|
||||||
|
카드는 항상 [코드 다시 받기] 를 함께 둔다.
|
||||||
|
|
||||||
|
**코드 글자에서 `0·O·1·I·L` 을 뺐다.** 잘못 읽어 실패하면 원인이 화면에 안 보이고
|
||||||
|
"연결이 안 된다" 로만 보인다.
|
||||||
|
|
||||||
|
## 일회성은 값이 아니라 CAS 가 보장한다
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE owner_kakao_links
|
||||||
|
SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
|
||||||
|
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
|
||||||
|
AND code_expires_at > now() AND code_attempts < :max
|
||||||
|
RETURNING user_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).
|
||||||
|
|
||||||
|
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
|
||||||
|
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
|
||||||
|
|
||||||
|
## 코드는 웹훅에서만 소비된다
|
||||||
|
|
||||||
|
`redeem()` 은 **공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 —
|
||||||
|
검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 메서드/경로 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /v1/agent/kakao/link` | 연결 상태. ★ 코드 평문은 주지 않는다 |
|
||||||
|
| `POST /v1/agent/kakao/link/code` | 일회용 코드 발급. 평문은 이 응답에서 한 번만 |
|
||||||
|
| `POST /v1/agent/kakao/link/disconnect` | 해제. 행은 `REVOKED` 로 남긴다 |
|
||||||
|
|
||||||
|
셋 다 `Cache-Control: no-store` · `Referrer-Policy: no-referrer` · `X-Robots-Tag: noindex` 다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
KAKAO_CHANNEL_PUBLIC_ID= # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
|
||||||
|
KAKAO_LINK_CODE_TTL_MIN=10
|
||||||
|
KAKAO_LINK_MAX_ATTEMPTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/agent_config.py` 는 `social_config.py` 와 **일부러 갈랐다.** SNS 게재는 되돌릴 수 없는
|
||||||
|
대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면
|
||||||
|
"이 값이 무엇을 여는가" 가 흐려진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2단계 — 도구 · 런타임 · 빌더 채팅창
|
||||||
|
|
||||||
|
`/sites` 화면 오른쪽 아래 **[말로 고치기]** 를 누르면 대화창이 열린다.
|
||||||
|
카카오 심사 없이 **에이전트 전체가 여기서 검증된다.**
|
||||||
|
|
||||||
|
## 겹
|
||||||
|
|
||||||
|
```
|
||||||
|
router/v1/agent/chat.py 빌더 화면 입구
|
||||||
|
router/v1/social/kakao_bot.py (4단계) 카톡 입구 — 같은 runtime.chat() 을 부른다
|
||||||
|
↓
|
||||||
|
services/agent/runtime.py 발화 → 도구 선택 → 실행 → 응답. ★ 채널을 모른다
|
||||||
|
services/agent/tools.py 레지스트리 — 할 수 있는 일의 전부 + 등급
|
||||||
|
↓
|
||||||
|
services/fact_service.py · site_service.py ★ 게이트가 사는 곳
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/prompts/agent.py` 가 "무엇을 묻는가" 를 갖는다(LLM 네 겹 규약, `services/llm/__init__.py`).
|
||||||
|
|
||||||
|
## 도구와 등급
|
||||||
|
|
||||||
|
| 등급 | 도구 | 대화에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| `READ` | `get_site_status` · `list_facts` | 바로 답한다 |
|
||||||
|
| `REVERSIBLE` | `set_fact` | 실행하고 알린다 |
|
||||||
|
| `SEMI` | `publish` | **실행 전에 한 번 묻는다** |
|
||||||
|
|
||||||
|
★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
|
||||||
|
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.
|
||||||
|
|
||||||
|
★ **결과 문구는 도구가 만든다.** LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
|
||||||
|
사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
|
||||||
|
|
||||||
|
★ **값을 고치면 재발행 안내를 함께 낸다.** fact 는 바뀌어도 사이트는 안 바뀐다 —
|
||||||
|
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
||||||
|
|
||||||
|
★ **모호하면 실행하지 않고 되묻는다.** 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시"
|
||||||
|
로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.
|
||||||
|
|
||||||
|
## 확인(SEMI) 한 바퀴
|
||||||
|
|
||||||
|
1. 발화 → 런타임이 `publish` 를 고른다 → **실행하지 않고** `needs_confirm=true` + 확인 문구
|
||||||
|
2. 화면이 [네, 해주세요] 를 띄운다
|
||||||
|
3. 누르면 `{confirm:{tool,args}}` 로 다시 POST → LLM 을 부르지 않고 그 도구를 실행
|
||||||
|
|
||||||
|
★ 서버는 돌아온 값을 **믿지 않는다.** 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
|
||||||
|
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다.
|
||||||
|
`READ` 등급은 확인 경로로 들어올 수 없다(`AGENT_UNKNOWN_TOOL`).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 메서드/경로 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /v1/agent/status` | 대화창을 열 수 있는지(LLM 키 유무) |
|
||||||
|
| `POST /v1/agent/chat/{place_id}` | `{message}` 또는 `{confirm:{tool,args}}` |
|
||||||
|
|
||||||
|
소유자 범위는 다른 엔드포인트와 같다 — 남의 `place_id` 는 **없는 것과 똑같이**
|
||||||
|
`PLACE_NOT_FOUND` 다. 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
| | 내용 | 심사 |
|
||||||
|
|---|---|---|
|
||||||
|
| 3 | 도구를 더 연다 — 사진 내리기 · 섹션 켜고 끄기 · 검색 노출 조회 | 없음 |
|
||||||
|
| 4 | 카카오 채널 웹훅을 **입구로 추가**(서명 검증 + `redeem` 연결) | 채널 + 챗봇 |
|
||||||
|
|
||||||
|
★ 도구를 늘릴 때도 **반드시 `services/*` 를 통과한다.** `crud` 를 직접 부르면 업종 스키마
|
||||||
|
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
|
||||||
|
`collect_service.store_facts` 가 크롤러에 걸어 둔 문과 같은 문이고,
|
||||||
|
`tests/test_agent_runtime.py` 가 소스에서 그 호출이 없는지 실제로 검사한다.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4단계 — 카카오 채널 웹훅
|
||||||
|
|
||||||
|
```
|
||||||
|
router/v1/agent/kakao_bot.py 시크릿 검증 · 카카오 형식 ↔ 우리 모양 ← 카카오를 아는 유일한 파일
|
||||||
|
services/agent/channel.py 신원 · 가게 고르기 · 확인 이어받기 ← 카카오를 모른다
|
||||||
|
services/agent/runtime.py 그대로 — 채널을 모른다
|
||||||
|
```
|
||||||
|
|
||||||
|
## ★★ 인증 — 오픈빌더는 서명을 주지 않는다
|
||||||
|
|
||||||
|
URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.user.id` 를 아무 값이나 넣으면
|
||||||
|
**그 사장님 행세를 한다.** 신원 연결이 통째로 무의미해지는 자리다.
|
||||||
|
|
||||||
|
| 겹 | 방법 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 공유 시크릿 — 헤더 `X-Agent-Secret` (`hmac.compare_digest`) |
|
||||||
|
| 2 | `KAKAO_BOT_ID` 대조 (시크릿이 아니라 오발송을 거르는 용도, 비워도 됨) |
|
||||||
|
| 3 | 헤더를 못 넣을 때만 경로 시크릿 `/webhook/{secret}` — **최후 수단**, 경로는 로그에 남는다 |
|
||||||
|
|
||||||
|
★ `KAKAO_WEBHOOK_SECRET` 이 비면 **엔드포인트가 404 다.** 401 로 답하면 "여기 뭔가 있다" 를
|
||||||
|
알려 준다. 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다.
|
||||||
|
|
||||||
|
## 빌더 화면과 다른 것 셋
|
||||||
|
|
||||||
|
| | 빌더 화면 | 카카오톡 |
|
||||||
|
|---|---|---|
|
||||||
|
| 신원 | 로그인 토큰 | 연결된 발화자 키 → `user_id` (★ **토큰을 발급하지 않는다**) |
|
||||||
|
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
|
||||||
|
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
|
||||||
|
|
||||||
|
★ **연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를
|
||||||
|
다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면
|
||||||
|
고친 것이 손님에게 보이는 줄 안다.
|
||||||
|
|
||||||
|
★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다.
|
||||||
|
임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.
|
||||||
|
"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다
|
||||||
|
(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).
|
||||||
|
|
||||||
|
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
|
||||||
|
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.
|
||||||
|
|
||||||
|
★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
|
||||||
|
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
|
||||||
|
|
||||||
|
## 5초 벽 — 콜백으로 넘는다
|
||||||
|
|
||||||
|
오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
|
||||||
|
|
||||||
|
★ **실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다.** 개발 중 잰 1.3~2.4초는 항목 두 개짜리
|
||||||
|
장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를
|
||||||
|
상한 근거로 삼으면 이렇게 틀린다.
|
||||||
|
|
||||||
|
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
|
||||||
|
|
||||||
|
```
|
||||||
|
카카오 → 우리 발화 + callbackUrl
|
||||||
|
우리 → 카카오 {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}} (즉답)
|
||||||
|
… 백그라운드에서 답을 만든다 (상한 45초)
|
||||||
|
우리 → 카카오 POST callbackUrl {"version":"2.0","template":{…}} (완성분)
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 콜백 주소는 **1분 · 1회**만 유효하다. 전송에 실패해도 **재시도하지 않는다** — 두 번째 POST 는
|
||||||
|
어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.
|
||||||
|
|
||||||
|
★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC = 4.5` 로 끊는다.
|
||||||
|
무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.
|
||||||
|
|
||||||
|
★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
KAKAO_WEBHOOK_SECRET= # 비면 웹훅이 404. python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
KAKAO_BOT_ID= # 선택
|
||||||
|
KAKAO_CHANNEL_PUBLIC_ID= # 채워야 연결 카드가 뜬다(채널 검색용 아이디, `_` 로 시작)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 오픈빌더에 등록할 주소
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<발행호스트>/v1/agent/kakao/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 스킬 설정에서 **커스텀 헤더**를 넣을 수 있으면 `X-Agent-Secret` 을 쓰고, 못 넣으면
|
||||||
|
`/v1/agent/kakao/webhook/<시크릿>` 을 쓴다.
|
||||||
|
|
||||||
|
★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
|
||||||
|
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.
|
||||||
61
docs/ALERTS.md
Normal file
61
docs/ALERTS.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# 장애 알림 (2026-09-15)
|
||||||
|
|
||||||
|
구현: `services/alert_service.py`(적재·재시도·중복 억제) · `services/teams_webhook.py`(전송) ·
|
||||||
|
`worker/runner.py` · `services/build_service.py` · `services/rollback_service.py`(발생 지점) ·
|
||||||
|
`scheduler/jobs.py`(발송·큐 정체 스윕). 전용 컨테이너 없음 — 기존 API·워커 프로세스가 한다.
|
||||||
|
|
||||||
|
## 무엇을 알리나
|
||||||
|
|
||||||
|
| kind | 언제 | dedupe_key |
|
||||||
|
|---|---|---|
|
||||||
|
| `job_dead` | 잡이 재시도를 소진해 DEAD | `job_dead:{JobType}:{place_id 또는 job_id}` |
|
||||||
|
| `build_failed` | BUILD·ROLLBACK 이 **게이트 반려가 아닌** 렌더·인프라 실패로 끝남 | `build_failed:{place_id}` |
|
||||||
|
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
|
||||||
|
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
|
||||||
|
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
|
||||||
|
|
||||||
|
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
|
||||||
|
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
|
||||||
|
|
||||||
|
## 중복 억제·재시도
|
||||||
|
|
||||||
|
`send_alert(kind, title, detail, dedupe_key)` — 같은 dedupe_key 로 "안 풀린"(resolved_at
|
||||||
|
NULL) 알림이 이미 있으면 새로 만들지 않는다. `resolve_alert(dedupe_key, ...)` 가 그 알림을
|
||||||
|
풀고 복구 알림을 한 번 보낸다. 실제 전송은 `scheduler.jobs.sweep_alert_outbox`(1분마다) —
|
||||||
|
실패하면 `crud/job_crud.compute_backoff` 와 같은 백오프로 최대 5회 재시도 후 `FAILED`(소진)로
|
||||||
|
멈춘다. `TEAMS_WEBHOOK_URL` 이 비어 있으면 적재만 되고 전송은 안 나간다(서버 동작엔 영향 없음).
|
||||||
|
|
||||||
|
`detail` 은 저장 **전에** `alert_service._scrub` 이 쿼리스트링 키·Bearer 토큰·`password=` 류·
|
||||||
|
이메일을 마스킹한다 — 외부 API 예외 메시지가 URL 에 키를 실어 보내는 경우가 있다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
TEAMS_WEBHOOK_URL= # Teams Workflows 수신 webhook. 비우면 알림이 DB(alert_outbox)에
|
||||||
|
# 쌓이기만 하고 안 나간다 — 서버는 그대로 뜬다.
|
||||||
|
ALERT_DEDUPE_WINDOW_MIN=60
|
||||||
|
```
|
||||||
|
|
||||||
|
`GSC_ALERT_WEBHOOK_URL`(search_console_alerts.py)과는 **다른 값**이다 — 색인 감시 전용과
|
||||||
|
이 잡 큐·발행 알림은 목적이 달라 의도적으로 분리했다(services/teams_webhook.py 머리주석).
|
||||||
|
|
||||||
|
## 서버·DB 전체 장애 — 이 알림 체계로는 못 잡는다
|
||||||
|
|
||||||
|
`alert_service`·`scheduler`가 도는 프로세스 자체가 죽으면(서버 다운·DB 완전 단절) 이 체계는
|
||||||
|
자기 장애를 자기가 못 알린다. **외부 감시가 필요하다** — uptime 모니터 등에서 주기적으로
|
||||||
|
`GET /readyz` 를 찌른다(`router/router.py`). `/healthz` 와 다르다: `/healthz` 는 프로세스
|
||||||
|
생존만(항상 200), `/readyz` 는 **DB 에 실제로 `SELECT 1` 을 던져** 200/503 을 가른다.
|
||||||
|
|
||||||
|
절차:
|
||||||
|
1. 외부 모니터가 `https://<host>/readyz` 를 1~5분 간격으로 확인한다.
|
||||||
|
2. 2xx 가 아니거나 타임아웃이면 **그 모니터 자신의 채널**로 알린다 — 이 레포의
|
||||||
|
`TEAMS_WEBHOOK_URL` 로 보내면 안 된다(webhook 이 죽은 서버 안에 있을 수 있다).
|
||||||
|
3. 이 모니터의 실제 설정(어느 서비스·어느 채널)은 이 세션에서 만들지 않았다 — 운영 계정·
|
||||||
|
외부 서비스 연결은 사용자 승인 후 진행한다.
|
||||||
|
|
||||||
|
## 아직 안 한 것 — 운영 미적용
|
||||||
|
|
||||||
|
- 실제 Teams Workflows webhook 생성·채널 지정 — mock 테스트만 했다(tests/test_alert_service.py).
|
||||||
|
- 외부 uptime 모니터 실제 연결(2절 3번).
|
||||||
|
- 마이그레이션(`0016_alert_outbox.sql`) 서버 적용.
|
||||||
|
- `alert_outbox` 오래된 SENT/FAILED 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).
|
||||||
@ -79,3 +79,19 @@ DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호
|
|||||||
|
|
||||||
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
|
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
|
||||||
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
||||||
|
|
||||||
|
## 5. SNS 비용 (2026-09-14)
|
||||||
|
|
||||||
|
사용자 결정: X 대신 Threads. [Meta 공식 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)에 직접 API 건당 과금·유료 티어가 안내돼 있지 않다. 현재 0원으로 분리 기록하되 영구 무료로 약속하지 않는다.
|
||||||
|
X는 현재 [URL 포함 생성 $0.20/요청](https://docs.x.com/x-api/getting-started/pricing)을 안내하므로 고정비라는 기존 계획을 폐기했다. X 어댑터는 넣지 않는다.
|
||||||
|
|
||||||
|
| 비용 | 처리 |
|
||||||
|
|---|---|
|
||||||
|
| 사이트당 변동비 | Gemini 초안, 알림톡 발송. 초안 최대 3회 재요청, 버전당 원고 1건. 알림톡 단가 미확정이라 활성화 전 계약 확인 |
|
||||||
|
| 계정/계약당 고정비 | 계약에 있다면 별도 운영비. 사이트 생성 CostMeter에 배분하지 않음 |
|
||||||
|
| 개발비 | 일회성 구현·심사 대응 비용. 사이트 원가와 분리 |
|
||||||
|
|
||||||
|
`Provider.THREADS`는 0원, 공개 가격 확정 근거가 없어 confirmed=False로 기록한다.
|
||||||
|
월 고정비라는 잘못된 근거로 confirmed=True를 넣지 않는다. `assert_rates_confirmed()`에 Threads를 포함하는
|
||||||
|
실배치는 운영 과금 확인 전 차단된다. 현재 SNS 서비스는 생성 호출 횟수 상한만 강제하며 누적 사이트 예산 연동은
|
||||||
|
별도 보완이 필요하다. Gemini 비용을 무료로 간주하지 않는다.
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# ARCHITECTURE
|
# ARCHITECTURE
|
||||||
|
|
||||||
|
> 2026-09-15: 현재 발행 실행·산출물 버전·배포는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
|
||||||
|
> 아래의 별도 프리렌더 컨테이너·폴링·전체 재굽기 설명은 이전 구조다.
|
||||||
|
|
||||||
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
|
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
|
||||||
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
|
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
|
||||||
|
|
||||||
@ -13,8 +16,14 @@
|
|||||||
```
|
```
|
||||||
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
||||||
out/payloads/.status/<slug>.json ──보고──▶
|
out/payloads/.status/<slug>.json ──보고──▶
|
||||||
|
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
|
||||||
```
|
```
|
||||||
|
|
||||||
|
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
|
||||||
|
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
|
||||||
|
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
|
||||||
|
알아야 한다.
|
||||||
|
|
||||||
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
||||||
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
||||||
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
||||||
@ -31,7 +40,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
|
|||||||
|
|
||||||
```
|
```
|
||||||
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
||||||
├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB)
|
├ build_snapshot → 원본 데이터(JSONB)
|
||||||
|
├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"]
|
||||||
|
│ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속)
|
||||||
|
│ → site_versions 행 insert
|
||||||
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
|
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
|
||||||
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
||||||
│
|
│
|
||||||
@ -60,6 +72,10 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
|||||||
|
|
||||||
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
|
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
|
||||||
|
|
||||||
|
Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해
|
||||||
|
사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다.
|
||||||
|
선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다.
|
||||||
|
|
||||||
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
|
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
|
||||||
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** —
|
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** —
|
||||||
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
|
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
|
||||||
@ -116,55 +132,9 @@ o2o-web4ai/
|
|||||||
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
|
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
|
||||||
│ └─ frontend/ 내부 운영 화면
|
│ └─ frontend/ 내부 운영 화면
|
||||||
│
|
│
|
||||||
├─ geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO)
|
|
||||||
│ ├─ naver/checks.py 밖에서 HTTP 로 본다 — DB 를 보지 않는다
|
|
||||||
│ └─ scripts/ 사람이 읽는 출력
|
|
||||||
│
|
|
||||||
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
|
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `geo/` — 최상단 모듈이면서 백엔드 코드를 쓴다 (2026-09-11)
|
|
||||||
|
|
||||||
검색·AI 엔진이 **밖에서** 우리 사이트를 어떻게 보는지 다룬다. `solution`(만든다)·
|
|
||||||
`admin`(운영한다)과 대상이 달라서 폴더를 가르고, 동시에 도메인 코드를 복제하지 않는다 —
|
|
||||||
**`admin/backend` 와 같은 방식**으로 `solution/backend` 를 PYTHONPATH 로 얹는다.
|
|
||||||
|
|
||||||
```
|
|
||||||
admin → solution/backend (place·fact 를 두 번 구현하지 않는다)
|
|
||||||
geo → solution/backend (네이버 API 쿼터 카운터 · publish_origin)
|
|
||||||
```
|
|
||||||
|
|
||||||
복제하면 조용히 틀리는 값만 빌려 쓴다 — 네이버 지역검색 호출기, `publish_origin()`,
|
|
||||||
외부 API 설정 객체. **읽어 쓰기만 한다: `solution/` 과 `admin/` 의 파일은 고치지 않는다**
|
|
||||||
(2026-09-11 제약). import 는 수정이 아니다.
|
|
||||||
|
|
||||||
★ **`solution` 은 `geo` 를 import 하지 않는다.** 그래서 라우터를 solution 의 라우터 트리에
|
|
||||||
끼우면 순환이다 — 마운트는 **진입점**이 한다(`admin/backend/app.py`, 또는 geo 자신의 진입점).
|
|
||||||
워커 핸들러도 같다: 껍데기는 `geo` 에 두고 등록만 진입점에서 한다.
|
|
||||||
이게 최상단으로 가른 대가이고, 동시에 경계가 지켜지는지 **import 한 줄로 드러나는** 이유다.
|
|
||||||
|
|
||||||
⚠️ 그 제약의 대가가 셋 있다. **웹문서검색 호출기가 `geo` 안에 따로 생겨 네이버 쿼터
|
|
||||||
카운터가 둘로 갈렸고**(앱당 일 25,000회를 공유하는데 백엔드 `call_counts()` 에 안 들어간다),
|
|
||||||
**`geo/` 가 백엔드 이미지에 없어 컨테이너에서 돌지 않으며**(레포 체크아웃 + 백엔드 venv 로만),
|
|
||||||
**소유확인이 랜딩 메타태그 대신 `nginx/site.conf` 의 파일 응답**이라 토큰이 두 곳에 산다
|
|
||||||
(그 대신 프론트 재빌드가 없다). 셋 다 [geo/README.md](../geo/README.md) '제약' 절에
|
|
||||||
옮길 자리까지 적어 뒀다.
|
|
||||||
|
|
||||||
**라우터는 아직 없다.** 공개하는 것은 `await naver.probe(...)` 하나이고 `Finding` 목록만
|
|
||||||
돌려준다 — 어디서 쓸지는 화면을 만들 때 정한다. 권고는 **:9801(어드민)** 이다
|
|
||||||
(소유확인·토큰은 우리 일이고, 위 포트 분리 근거가 그대로 적용된다).
|
|
||||||
|
|
||||||
⚠️ **모듈 안에서 두 출처를 섞지 않는다.** `naver/checks.py` 는 밖에서 HTTP 로만 보고 **DB 를
|
|
||||||
보지 않는다**(세션을 인자로도 받지 않는다). "우리 DB 가 그렇다고 한다" 와 "밖에서 실제로
|
|
||||||
그렇게 보인다" 가 한 함수에 섞이면 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 —
|
|
||||||
그 어긋남을 찾는 것이 이 모듈의 존재 이유다.
|
|
||||||
|
|
||||||
⚠️ **소유확인처럼 "심는" 일은 이 모듈이 하지 않는다.** HTML 을 만드는 쪽(`solution/frontend`
|
|
||||||
랜딩 `<head>`)이 심고, 이 모듈은 그것이 밖에서 실제로 보이는지만 본다. 한 곳에 두면
|
|
||||||
"내가 심었으니 있다" 를 확인이라고 부르게 된다.
|
|
||||||
|
|
||||||
경계와 아직 안 만든 것(표·잡·GEO 측정)은 [geo/README.md](../geo/README.md).
|
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
||||||
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
|
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
|
||||||
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
||||||
|
|||||||
@ -7,13 +7,15 @@
|
|||||||
여기서는 그 앞뒤를 잇는다.
|
여기서는 그 앞뒤를 잇는다.
|
||||||
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
|
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
|
||||||
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
|
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
|
||||||
|
- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다.
|
||||||
|
컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md).
|
||||||
|
|
||||||
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
|
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
|
||||||
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
|
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 표 14개, 스키마는 `public` 한 벌
|
## 0. 표 17개, 스키마는 `public` 한 벌
|
||||||
|
|
||||||
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
||||||
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
||||||
@ -27,13 +29,16 @@ users 사장님 계정
|
|||||||
│ ├ place_photos 사진
|
│ ├ place_photos 사진
|
||||||
│ ├ place_facts ★ 사실. 이 제품의 심장
|
│ ├ place_facts ★ 사실. 이 제품의 심장
|
||||||
│ ├ place_faqs FAQ
|
│ ├ place_faqs FAQ
|
||||||
|
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
|
│ ├ place_social_posts SNS 게재 글 — 초안 → 승인 → 게시 (사장님이 누를 때만)
|
||||||
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
||||||
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
||||||
└ sites 발행 사이트 — 사업장당 1개
|
└ sites 발행 사이트 — 사업장당 1개
|
||||||
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
||||||
├ site_versions ★ 빌드 버전 — snapshot 박제
|
├ site_versions ★ 빌드 버전 — snapshot 박제
|
||||||
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
||||||
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기
|
owner_social_accounts 사장님이 연결한 SNS 계정 — ★ 위임받은 토큰을 보관하는 유일한 표
|
||||||
|
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래 · SNS
|
||||||
```
|
```
|
||||||
|
|
||||||
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
||||||
@ -121,6 +126,23 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
|
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
|
||||||
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
|
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
|
||||||
|
|
||||||
|
**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`).
|
||||||
|
예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로
|
||||||
|
막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는
|
||||||
|
값이라 승인할 화면을 이미 지나가 있었다.
|
||||||
|
|
||||||
|
지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다.
|
||||||
|
|
||||||
|
| 그 key 의 현재 노출값 | 수집값이 오면 |
|
||||||
|
|---|---|
|
||||||
|
| 없음 | 바로 노출값(VERIFIED). `verified_by` 는 **비운다** — 사람이 승인한 이력과 구별된다 |
|
||||||
|
| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 |
|
||||||
|
| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 |
|
||||||
|
| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) |
|
||||||
|
|
||||||
|
즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다.
|
||||||
|
UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.
|
||||||
|
|
||||||
### `place_channels` — 크롤링 대상 URL
|
### `place_channels` — 크롤링 대상 URL
|
||||||
|
|
||||||
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
|
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
|
||||||
@ -134,8 +156,52 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
|
|
||||||
### `place_faqs` — FAQ
|
### `place_faqs` — FAQ
|
||||||
|
|
||||||
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
|
출처(`generated_by`)마다 근거 요구가 다르다.
|
||||||
데이터 모양으로 강제된 자리다.
|
|
||||||
|
| generated_by | 무엇 | source_fact_ids | 어디에 나가나 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `LLM`(4) | 확인된 fact 로 쓴 문장 | 근거 key 필수 — 없으면 저장하지 않는다(`copy_service`) | 화면 · JSON-LD · llms.txt |
|
||||||
|
| `OWNER`(1) | 사장님이 쓰거나 고친 문장 | 없을 수 있다 | 화면 · JSON-LD · llms.txt |
|
||||||
|
| `TEMPLATE`(5) | 20개를 채운 공통 질문 + 문의 안내 답 | 없음 | **화면만** |
|
||||||
|
|
||||||
|
★ 예전 문서는 "비면 발행 게이트가 반려한다" 고 적었지만 그런 검사는 없었다(2026-09-14 확인).
|
||||||
|
근거 강제는 저장 시점(`copy_service`)에 있다. 채우기 규칙은 [DECISIONS 8절](DECISIONS.md).
|
||||||
|
|
||||||
|
### `place_songs` — 이 숙소의 노래
|
||||||
|
|
||||||
|
발행할 때마다 한 곡 만든다. 가사는 소개문과 **같은 재료**(확인된 fact + 조사 근거 + 소개문)로
|
||||||
|
Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
||||||
|
|
||||||
|
★ **검증 상태(`FactStatus`)가 없다.** 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(`SongStatus`:
|
||||||
|
`GENERATING` · `READY` · `FAILED`). 스냅샷은 **`READY` 만** 싣는다.
|
||||||
|
|
||||||
|
★ **`origin_url`(Suno 가 준 주소)은 발행본에 나가지 않는다.** 만료되는 주소라 그대로 실으면
|
||||||
|
발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 `solution/site/songs/<song_id>.mp3`
|
||||||
|
에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s/<slug>/<song_id>.mp3`)만 나간다.
|
||||||
|
표에는 추적용으로만 남긴다.
|
||||||
|
|
||||||
|
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
|
||||||
|
|
||||||
|
### `place_social_posts` · `owner_social_accounts` — SNS 게재
|
||||||
|
|
||||||
|
사장님이 [SNS에 알리기] 를 누를 때만 생긴다. 발행의 부수효과가 아니다 — 발행은 우리 화면을
|
||||||
|
굽는 일이고, 이건 **사장님이 자기 이름으로 하는 말**이다(DECISIONS 8절).
|
||||||
|
|
||||||
|
★ **승인 대기는 잡이 아니라 이 표의 상태다.** 잡으로 매달면 lease(120초)가 만료돼 reaper 가
|
||||||
|
회수하고 attempts 가 올라 결국 DEAD 가 된다. 큐는 "지금 할 일" 만 표현한다.
|
||||||
|
상태: `DRAFTING → PENDING_APPROVAL → APPROVED → POSTING → POSTED`(+ `DECLINED`·`EXPIRED`·
|
||||||
|
`FAILED`·`UNKNOWN`). **발행본에는 `POSTED` 만 나간다.**
|
||||||
|
|
||||||
|
★ `POSTING` 이 10분 넘게 남아 있으면 `UNKNOWN` 으로 내린다 — **시간을 근거로 `APPROVED` 로
|
||||||
|
되돌리지 않는다.** 외부가 이미 받았을 수 있고, 되돌리면 같은 글이 두 번 올라간다.
|
||||||
|
|
||||||
|
★ `approval_token_sha` 는 **해시만** 저장한다(원문은 링크에만 있다). 일회성은 토큰이 아니라
|
||||||
|
`status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다(DECISIONS 8-3).
|
||||||
|
|
||||||
|
★ `owner_social_accounts` 는 **place 가 아니라 user 에 붙는다.** 계정은 사람의 것이고, 사장님이
|
||||||
|
업장을 둘 가져도 계정은 하나다. 토큰은 `SOCIAL_TOKEN_SECRET` 으로 암호화해 넣는다 —
|
||||||
|
이 표만이 위임받은 자격증명을 담는다(`place_channels` 는 공개 URL 목록이라 섞지 않는다).
|
||||||
|
|
||||||
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
||||||
|
|
||||||
@ -145,7 +211,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 WEATHER | Open-Meteo | — |
|
| 1 WEATHER | Open-Meteo | — |
|
||||||
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
|
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
|
||||||
| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` |
|
| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` |
|
||||||
|
|
||||||
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
|
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
|
||||||
|
|
||||||
@ -203,6 +269,9 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
|
|
||||||
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
|
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
|
||||||
|
|
||||||
|
COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
|
||||||
|
생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md).
|
||||||
|
|
||||||
| `job_type` | 핸들러 | 하는 일 |
|
| `job_type` | 핸들러 | 하는 일 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
|
||||||
@ -283,3 +352,15 @@ cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common
|
|||||||
|
|
||||||
2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
|
2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
|
||||||
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
|
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
|
||||||
|
|
||||||
|
## SNS (2026-09-14)
|
||||||
|
|
||||||
|
| 표 | 키·범위 | 데이터·인덱스 |
|
||||||
|
|---|---|---|
|
||||||
|
| owner_social_accounts (0012) | account_id, user_id/provider | provider_user_id·handle·profile_url, 암호화 access/refresh token·만료·scopes·status·last_error. deleted=false, linked/needs_reauth인 user/provider 부분 유니크 |
|
||||||
|
| place_social_posts (0013) | post_id, place_id/user_id/site_version_id | 승인 계정 account_id, provider·본문·고정 URL·grounded_facts, nonce 해시·시각·채널, 게시 ID·permalink·posted_at·last_error. 같은 place/version은 삭제 전까지 유니크. POSTED 최신 조회 인덱스 |
|
||||||
|
|
||||||
|
Provider 1=X 예약값(구현 없음), 2=Threads. 상태는 DRAFTING/DRAFT/PENDING_APPROVAL/APPROVED/POSTING/POSTED/DECLINED/EXPIRED/FAILED/UNKNOWN.
|
||||||
|
DRAFT는 복사 가능한 작성 완료 원고, UNKNOWN은 중복 방지를 위한 수동 확인 상태다.
|
||||||
|
SNS 승인 CAS와 잡 삽입은 같은 트랜잭션. SOCIAL_DRAFT=8, SOCIAL_POST=9, 승인 대기는 잡이 아니다.
|
||||||
|
POSTED 최신 3건만 snapshot → payload.socialPosts로 전달한다. 자격증명·nonce·근거 원문은 제외한다.
|
||||||
|
|||||||
@ -34,8 +34,29 @@
|
|||||||
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
|
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
|
||||||
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
|
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
|
||||||
|
|
||||||
|
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
|
||||||
|
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
|
||||||
|
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
|
||||||
|
|
||||||
|
| | 지금 |
|
||||||
|
|---|---|
|
||||||
|
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
|
||||||
|
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
|
||||||
|
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
|
||||||
|
|
||||||
|
- 레지스트리가 `yanolja` 를 `static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
|
||||||
|
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
|
||||||
|
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html` 의 `_DENY_HOSTS` 에서 `yanolja.com` ·
|
||||||
|
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
|
||||||
|
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
|
||||||
|
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
|
||||||
|
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다** —
|
||||||
|
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
|
||||||
|
|
||||||
### 1-2. 크롤링한 **이미지**의 재게시 권리
|
### 1-2. 크롤링한 **이미지**의 재게시 권리
|
||||||
|
|
||||||
|
2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다.
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | **미결** |
|
| 상태 | **미결** |
|
||||||
@ -58,6 +79,8 @@
|
|||||||
|
|
||||||
### 1-4. 해지 시 사이트 처리 정책
|
### 1-4. 해지 시 사이트 처리 정책
|
||||||
|
|
||||||
|
2026-09-14: SNS 운영 게재의 선행조건으로 승격. 외부 링크는 남으므로 UNPUBLISHED는 안내+연락처 페이지여야 한다. 현재 상태 전이만 있고 안내 페이지 생성은 미구현이므로 자동 게재 플래그는 기본 OFF다. 사장님 글을 자동 삭제하지 않는다. 함께 삭제할지는 별도 명시적 선택이며 현재 삭제 API는 제공하지 않는다.
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | **미결** |
|
| 상태 | **미결** |
|
||||||
@ -282,3 +305,104 @@ LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠
|
|||||||
손댔는지 알 수 없다. `expire_generated` 는 `generated_by` 로 가른다 — 사장님이 정정하면
|
손댔는지 알 수 없다. `expire_generated` 는 `generated_by` 로 가른다 — 사장님이 정정하면
|
||||||
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
|
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
|
||||||
반려(`REJECTED`)한 FAQ 는 그대로 둔다.
|
반려(`REJECTED`)한 FAQ 는 그대로 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7-1. 사장님 명의의 SNS 발화는 별도 승인 (2026-09-14)
|
||||||
|
|
||||||
|
Threads 우선. 상세 흐름·활성화 전제는 [SOCIAL.md](SOCIAL.md).
|
||||||
|
|
||||||
|
| 기준 | 우리 발행본(7절) | SNS 게재 |
|
||||||
|
|---|---|---|
|
||||||
|
| 명의 | 우리 사이트 | 사장님 개인 계정 |
|
||||||
|
| 회수 | 에디터 수정 후 재빌드 | 플랫폼 사본·인용·캐시를 회수할 수 없음 |
|
||||||
|
| 주요 오류 | 문장 내용, 앞의 사실 게이트 | 명의·주소, LLM이 결정하지 않는 값 |
|
||||||
|
|
||||||
|
폰에서 로그인 없이 확인하고, 화면과 알림톡 두 경로를 둔다. 미승인은 EXPIRED로 남기고
|
||||||
|
게시/발송 실패도 카드에 남긴다. 초안 생성과 발송을 별도 요청으로 나눠 알림톡 실패를
|
||||||
|
초안 생성 성공으로 숨기지 않는다. GET은 승인하지 않는다. 토큰은 nonce와 DB 해시이며 JWT가 아니다.
|
||||||
|
|
||||||
|
POSTING 중단은 UNKNOWN으로 격리한다. 10분 지났다고 자동 재시도하는 설계는 취소한다.
|
||||||
|
게시할 때 승인된 account_id·본문·주소를 재검사한다. 계정 없이 확인한 원고는 나중에 연결해도
|
||||||
|
자동으로 게재하지 않고 다시 승인받는다. 사진 첨부 코드는 없다.
|
||||||
|
|
||||||
|
### 7-1-1. 게시는 주소가 확정된 사이트에만 — ★ 이 기능에서 가장 위험한 자리
|
||||||
|
|
||||||
|
`sites.domain` 이 비어 있어도 사이트는 발행된다. 그때 슬러그는 `_publish_target` 이 만드는
|
||||||
|
임시값이고 **`place.name` 에서 파생된다.** 상호를 고치면 **발행 주소가 통째로 바뀐다.**
|
||||||
|
`set_slug` 의 `SITE_SLUG_LOCKED` 는 `domain` 컬럼 변경만 막으므로 여기엔 안 걸린다.
|
||||||
|
→ 이미 올라간 글의 옛 주소는 404 가 되고, **그 글은 수정할 수 없다.**
|
||||||
|
|
||||||
|
그래서 전제조건을 코드가 강제한다(`social_service.target`):
|
||||||
|
`status == PUBLISHED` **AND** `current_version_id IS NOT NULL` **AND** `domain IS NOT NULL`.
|
||||||
|
임시 슬러그는 "아직 이름이 정해지지 않았다" 는 뜻이지 주소가 아니다.
|
||||||
|
|
||||||
|
### 7-1-2. 승인 링크 — 일회성은 토큰이 아니라 CAS 가 보장한다
|
||||||
|
|
||||||
|
JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 세기** 때문이다. 승인은
|
||||||
|
`status='PENDING_APPROVAL'` 조건이 붙은 **단일 UPDATE ... RETURNING** 이고 두 번째 클릭은 0행이다.
|
||||||
|
|
||||||
|
★ **승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저 프리페치가
|
||||||
|
**사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이 올라가고 로그에는
|
||||||
|
"승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
|
||||||
|
|
||||||
|
**2026-09-21 개정 — 미니블로그 문구 재사용은 예외.** 미니블로그 승인(이메일 GET 토큰 또는
|
||||||
|
로그인 "바로 발행")은 "이 문구를 공개해도 좋다"는 사장님의 명시적 의사표시이고, 같은 문구를
|
||||||
|
같은 시점에 다른 채널(쓰레드)에도 내보내는 것뿐이므로 별도 승인은 중복 확인이다. 이 예외는
|
||||||
|
**미니블로그 문구를 그대로 재사용하는 경우에 한정**한다 — `social_service.publish_reused_text`
|
||||||
|
가 `decided_via='mini_blog'`로 곧장 `APPROVED` 처리한다. 쓰레드 전용으로 새로 짓거나 내용을
|
||||||
|
바꾸는 경로(`create_draft`/`request_approval`)는 위 CAS 승인을 그대로 거친다.
|
||||||
|
|
||||||
|
★ **기존 액세스 토큰을 승인 링크에 얹지 않는다.** 지금 JWT 는 `sub` 에 `UserInfo` 통짜(role 포함)를
|
||||||
|
넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다.
|
||||||
|
|
||||||
|
### 7-1-3. 사진은 올리지 않는다 — 1-2 의 격리가 여기서는 불가능하다
|
||||||
|
|
||||||
|
1-2(크롤링 이미지 재게시)의 격리는 "결론이 불가면 `source_type=CRAWL` 을 발행 payload 에서
|
||||||
|
빼면 된다" 즉 **되돌릴 수 있다**는 전제 위에 있다. SNS 는 그 전제가 깨진다 — 플랫폼 서버에
|
||||||
|
사본이 생기고, 핫링크를 줘도 플랫폼이 자기 CDN 에 캐시한다. 게다가 지금은 **OWNER 사진이
|
||||||
|
존재할 수 없다**(업로드 경로가 없다, 5-3).
|
||||||
|
→ `source_type` 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않는다.** 필터로 만들면 1-2 가
|
||||||
|
풀리기 전에 OWNER 업로드가 붙는 날 자동으로 열린다.
|
||||||
|
|
||||||
|
### 7-1-4. 실제 게시는 기본으로 꺼져 있다 — 그리고 1-4 가 전제조건이 됐다
|
||||||
|
|
||||||
|
`SOCIAL_POSTING_ENABLED=1` 일 때만 열린다. 초안·승인까지는 계약 없이 돌지만 **게시는
|
||||||
|
되돌릴 수 없어서**, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다.
|
||||||
|
|
||||||
|
★ 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이 **"죽은 링크 정책 미정"** 이 된다.
|
||||||
|
색인은 시간이 지나면 사라지지만 사장님 타임라인에 박힌 링크는 우리가 손댈 수 없다.
|
||||||
|
`UNPUBLISHED` 를 404 로 두면 SNS 에서 온 손님은 빈 화면을 본다.
|
||||||
|
그리고 **우리가 사장님 글을 자동으로 지우지 않는다** — 지우는 것도 사장님 명의의 행위다.
|
||||||
|
|
||||||
|
남은 정책: 만료 24시간의 최종 근거, 야간 발송(현재 화면 채널만 사용), 다계정 선택,
|
||||||
|
장기 미사용 계정의 사전 토큰 갱신. 계정은 현재 user/provider당 하나다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14)
|
||||||
|
|
||||||
|
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
|
||||||
|
산하연 풀빌라 fact 4건 · FAQ 4건.
|
||||||
|
|
||||||
|
**어떻게**
|
||||||
|
- 생성 상한 `max_faqs` 8 → 20 (`services/faq_fill.FAQ_TARGET`).
|
||||||
|
- 노출 중 FAQ 가 20개에 모자라면 업종 카탈로그(`common/faq_catalog/resources/pension.json`, 30문항)에서
|
||||||
|
**겹치지 않는** 질문을 카탈로그 순서대로 고른다. 건너뛰는 것:
|
||||||
|
- 답할 fact 가 있는 질문 — LLM 이 fact 로 답할 자리다. 프롬프트에 그 질문들을 실어 먼저 쓰게 한다.
|
||||||
|
- 기존 FAQ(생성분·사장님 입력·정정분)와 **근거 fact key** 가 겹치거나 **질문 키워드**가 겹치는 질문.
|
||||||
|
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
|
||||||
|
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
|
||||||
|
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
|
||||||
|
(`frontend … canvas/variants/faq/useFaqList.ts` 주석).
|
||||||
|
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
|
||||||
|
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
|
||||||
|
그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —
|
||||||
|
이제 그 거절은 **카탈로그가 없는 업종**(카페·음식점·호텔)에만 남는다.
|
||||||
|
|
||||||
|
**어디에 안 나가나** — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ
|
||||||
|
점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라
|
||||||
|
고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다.
|
||||||
|
|
||||||
|
**적용 범위** — 숙박 업종이면서 외부 분류(`places.external_category`)가 호텔·모텔·리조트가 아닌 곳.
|
||||||
|
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# 배포 · 스토리지
|
# 배포 · 스토리지
|
||||||
|
|
||||||
|
> 2026-09-15 이후 절차는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md)를 따른다.
|
||||||
|
> 기존 사이트 전체 재굽기는 하지 않는다. 최초 전환 때 구 프리렌더를 중지한다.
|
||||||
|
|
||||||
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
|
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
|
||||||
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
|
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
|
||||||
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
|
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
|
||||||
@ -178,7 +181,7 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
|
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
|
||||||
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
|
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
|
||||||
| 네이버 서치어드바이저 | **HTML 파일** (DNS TXT 를 안 받는다) | `nginx/site.conf` 가 직접 내준다 (**git 에 없다** — 서버 로컬) |
|
| 네이버 서치어드바이저 | 메타태그 / HTML 파일 (**DNS TXT 를 안 받는다**) | 아직 안 붙였다 |
|
||||||
|
|
||||||
★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로
|
★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로
|
||||||
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
|
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
|
||||||
@ -192,41 +195,6 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
|
|||||||
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
|
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 네이버 — nginx 가 파일을 내준다
|
|
||||||
|
|
||||||
네이버는 구글처럼 DNS 로 끝낼 수 없다(**DNS TXT 를 안 받는다**). 남은 것은 메타태그와
|
|
||||||
HTML 파일 둘이고, **파일 + nginx** 로 간다.
|
|
||||||
|
|
||||||
메타태그를 안 쓰는 이유: 랜딩 `<head>` 는 `solution/frontend` 가 만들고 값이 `VITE_*` 로
|
|
||||||
**번들에 구워진다** — 토큰을 바꿀 때마다 `./deploy.sh solution-site` 재빌드가 필요하다.
|
|
||||||
`nginx/site.conf` 는 **바인드 마운트**라 고치고 reload 하면 끝이고, `solution/` 을 건드리지
|
|
||||||
않는다.
|
|
||||||
|
|
||||||
★★ **그냥 파일을 올리는 것으로는 안 된다.** 오리진 루트의 `*.html` 은 nginx 맨 아래
|
|
||||||
`location /` 의 SPA 폴백으로 떨어져 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다
|
|
||||||
(전용 블록이 있는 건 `.txt` 뿐이다 — IndexNow 키). 그래서 **`location =` 블록이 필수**다.
|
|
||||||
|
|
||||||
1. 서치어드바이저 > 사이트 관리 > 소유확인에서 **HTML 파일** 방식을 골라 파일명을 확인
|
|
||||||
(`naver1234abcd.html` 꼴)
|
|
||||||
2. 서버의 `nginx/site.conf` 에서 "네이버 서치어드바이저 소유확인" 블록의 주석을 풀고
|
|
||||||
`<토큰>` 두 자리를 파일명(`.html` 제외)으로 바꾼다
|
|
||||||
3. 루트 `.env` 에 `NAVER_SITE_VERIFICATION=<토큰>` — 점검이 대조할 기대값이다
|
|
||||||
(★ 두 곳이다. nginx 가 env 를 못 읽어서 그렇고, 어긋나면 4번이 잡는다)
|
|
||||||
4. `docker compose restart solution-site` (또는 nginx reload). **재빌드는 필요 없다**
|
|
||||||
5. 레포 루트에서 `solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py` —
|
|
||||||
**상태코드가 아니라 내용으로** 본다. "200 이지만 빌더 앱 HTML 이 나온다" 면 2번의 블록이
|
|
||||||
없거나 파일명이 다른 것이다
|
|
||||||
6. 서치어드바이저에서 [소유확인]
|
|
||||||
|
|
||||||
★ **등록은 한 번이 끝이 아니다.** 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이
|
|
||||||
사라진 시점에 등록이 풀리면서 **아무 알림도 오지 않는다.**
|
|
||||||
`site.conf` 는 git 에 없으므로 **서버를 새로 세우면 2번을 다시 해야 한다** — 빼먹으면
|
|
||||||
며칠 뒤 조용히 풀린다. 판정 코드는 `geo/naver/checks.py` 한 곳이다.
|
|
||||||
|
|
||||||
★ **등록 전에는 창이 없다.** 사이트맵 제출·웹페이지 수집 요청·색인 진단이 전부 등록된
|
|
||||||
사이트에만 열린다. IndexNow 통보는 등록 없이도 동작하지만, **통보가 먹었는지 볼 방법이
|
|
||||||
없다** — 그래서 이 단계가 네이버 쪽 나머지 작업의 전제다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 나중에 — Azure Blob 을 켤 때
|
## 4. 나중에 — Azure Blob 을 켤 때
|
||||||
|
|||||||
784
docs/DEVLOG.md
784
docs/DEVLOG.md
@ -1,110 +1,722 @@
|
|||||||
# 개발 일지
|
# 개발 일지
|
||||||
|
|
||||||
|
## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
|
||||||
|
|
||||||
|
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
||||||
|
로 끝났다. 타임아웃이었다.
|
||||||
|
|
||||||
|
★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드
|
||||||
|
두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다.
|
||||||
|
"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다.
|
||||||
|
|
||||||
|
**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회):
|
||||||
|
`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서
|
||||||
|
답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한).
|
||||||
|
|
||||||
|
★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는
|
||||||
|
이미 "확인하고 있어요" 가 가 있다.
|
||||||
|
|
||||||
|
★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도
|
||||||
|
`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다.
|
||||||
|
|
||||||
|
**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패).
|
||||||
|
|
||||||
|
## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기
|
||||||
|
|
||||||
|
실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는
|
||||||
|
대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다.
|
||||||
|
|
||||||
|
- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게.
|
||||||
|
- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다.
|
||||||
|
- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 —
|
||||||
|
대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다.
|
||||||
|
- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 —
|
||||||
|
사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다.
|
||||||
|
|
||||||
|
**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가).
|
||||||
|
전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다.
|
||||||
|
|
||||||
|
## 2026-09-22 — 카카오 채널 웹훅(4단계)
|
||||||
|
|
||||||
|
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게
|
||||||
|
만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태
|
||||||
|
(`channel.py`)뿐이다.
|
||||||
|
|
||||||
|
**★★ 인증 — 오픈빌더는 서명을 주지 않는다**
|
||||||
|
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
|
||||||
|
1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`,
|
||||||
|
`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가
|
||||||
|
404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다.
|
||||||
|
|
||||||
|
**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다.
|
||||||
|
1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다**
|
||||||
|
(카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다)
|
||||||
|
2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억.
|
||||||
|
★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다**
|
||||||
|
3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022).
|
||||||
|
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다**
|
||||||
|
|
||||||
|
**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로
|
||||||
|
끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에.
|
||||||
|
어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다.
|
||||||
|
|
||||||
|
**밟은 것** — `DB_SESSION_MNG.execute_lambda` 는 **람다 반환값을 그대로** 준다(CRUD 관례가
|
||||||
|
`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든
|
||||||
|
예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다.
|
||||||
|
|
||||||
|
**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출).
|
||||||
|
전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다.
|
||||||
|
|
||||||
|
## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐)
|
||||||
|
|
||||||
|
카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을
|
||||||
|
`0` → `1` 로 돌렸다. **코드는 어제도 오늘도 그대로다** — 닫고 여는 일이 커밋을 되짚는 일이
|
||||||
|
되면 안 된다는 어제 판단이 하루 만에 값을 쳤다.
|
||||||
|
|
||||||
|
★ 기본을 켜도 **LLM 키가 없으면 안 열린다**(`runtime.is_configured` 가 스위치와 키를 둘 다
|
||||||
|
본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다.
|
||||||
|
|
||||||
|
★ 카카오 연결 카드는 아직 감춰져 있다 — `KAKAO_CHANNEL_PUBLIC_ID` 미설정.
|
||||||
|
채우면 코드는 발급되지만 **소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.**
|
||||||
|
채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이고, 웹훅이 붙는 쪽은 후자다.
|
||||||
|
|
||||||
|
**검증** — `test_agent_runtime`(스위치 테스트를 새 기본값에 맞춰 갱신)·`test_kakao_link` 34 passed.
|
||||||
|
|
||||||
|
## 2026-09-21 — 에이전트 화면 보류: 설정으로 닫는다(코드는 그대로)
|
||||||
|
|
||||||
|
카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다(사장님 지시: "이 작업은 여기서 딱
|
||||||
|
보류하고, 사용못하게 대화 할 수 있는 부분을 숨겨줘"). 채널이 없으면 대화창은 사장님에게
|
||||||
|
**어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다.
|
||||||
|
|
||||||
|
- `AGENT_CHAT_ENABLED` 신설(기본 `0`). `runtime.is_configured()` 가 스위치와 LLM 키를 **둘 다**
|
||||||
|
본다 — 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다.
|
||||||
|
- `AgentChatDock` · `KakaoChannelCard` 둘 다 조건 미충족이면 `return null` 로 통째로 감춘다.
|
||||||
|
연결 카드는 `connection_enabled=false` 가 기준이라 설정을 채우면 그대로 다시 나타난다.
|
||||||
|
- ★ **코드를 지우지 않았다.** 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다.
|
||||||
|
|
||||||
|
★ Threads 카드와 판단이 갈린 것이 맞다 — 저쪽은 '자리는 두고 버튼만 죽인다'(사장님이 곧 쓸 수
|
||||||
|
있는 기능이라 존재를 알려야 했다), 이쪽은 언제 열릴지 말해 줄 수 없어 감춘다.
|
||||||
|
|
||||||
|
**검증** — `test_agent_runtime`(스위치 테스트 2건 추가)·`test_kakao_link` 34 passed.
|
||||||
|
`npm run lint` 통과.
|
||||||
|
|
||||||
|
## 2026-09-21 — 사장님 에이전트 2단계: 도구 레지스트리 · 런타임 · 빌더 채팅창
|
||||||
|
|
||||||
|
**왜 카카오톡보다 이걸 먼저 만드나**
|
||||||
|
런타임이 채널을 모르므로, 채널·챗봇 심사 없이 **에이전트 전체를 빌더 화면에서 검증**할 수 있다.
|
||||||
|
웹훅 핸들러 안에 에이전트를 짜면 빌더에서 같은 걸 못 쓰고 심사가 끝나야 무엇 하나 확인되지 않는다.
|
||||||
|
카톡은 나중에 붙는 두 번째 입구다 — `runtime.chat()` 을 그대로 부른다.
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `services/agent/tools.py` — 도구 넷과 등급 셋(`READ`·`REVERSIBLE`·`SEMI`).
|
||||||
|
`get_site_status`·`list_facts`·`set_fact`·`publish`.
|
||||||
|
- `services/agent/runtime.py` — 발화 → 도구 선택(LLM 1콜) → 실행 → 응답. 채널을 모른다.
|
||||||
|
- `services/prompts/agent.py` — LLM 네 겹 규약(`services/llm/__init__.py`)대로 프롬프트만 여기.
|
||||||
|
- `router/v1/agent/chat.py`, 프론트 `features/agent/AgentChatDock.tsx`(`/sites` 우하단).
|
||||||
|
|
||||||
|
**세 가지를 모델에게 맡기지 않았다**
|
||||||
|
1. **등급** — 확인이 필요한지는 레지스트리가 못 박는다. 응답 스키마에 그 칸 자체가 없고
|
||||||
|
도구 목록에도 등급을 싣지 않는다. 모델이 정하면 프롬프트에 끼어든 한 줄이 확인을 건너뛴다.
|
||||||
|
2. **결과 문구** — 도구가 만든다. 모델이 쓰면 **하지 않은 일을 했다고 말할 수 있고**
|
||||||
|
사장님에게는 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
|
||||||
|
3. **key** — `set_fact` 의 key 는 업종 스키마가 최종 판정이다. 모델이 없는 key 를 지어낸다.
|
||||||
|
|
||||||
|
**확인(SEMI) 한 바퀴** — `publish` 는 고르기만 하고 실행하지 않는다. 화면이 [네, 해주세요] 를
|
||||||
|
띄우고, 누르면 `{confirm:{tool,args}}` 로 다시 온다. ★ 서버는 그 값을 믿지 않는다 — 도구는
|
||||||
|
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다. 확인 절차가 검증을 건너뛰는 구멍이 되면 안 된다.
|
||||||
|
|
||||||
|
**값을 고치면 재발행 안내를 함께 낸다** — fact 는 바뀌어도 사이트는 안 바뀐다.
|
||||||
|
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
||||||
|
|
||||||
|
**검증** — `test_agent_runtime.py` 17 passed. 그중 하나는 `tools.py` 소스에서 `crud` 직접 호출이
|
||||||
|
없는지 실제로 검사한다(주석이 아니라 코드로 못 박는 자리). 테스트는 LLM 을 monkeypatch 해서
|
||||||
|
실제 모델을 부르지 않는다. `npm run lint` 통과.
|
||||||
|
|
||||||
|
## 2026-09-21 — 사장님 에이전트 1단계: 카카오톡 채널 신원 연결
|
||||||
|
|
||||||
|
**왜 이것부터인가**
|
||||||
|
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 `user_id` 와 관계가 없다.
|
||||||
|
다른 엔드포인트는 전부 `place_crud.get_place(s, owner_user_id, place_id)` 로 소유자 범위를
|
||||||
|
지키는데, 채널에서 온 발화에는 그 `owner_user_id` 를 줄 근거가 없다 — 매핑이 없으면
|
||||||
|
**채널 진입점만 소유자 범위 밖**에 놓이고 채널에 말을 건 아무나가 남의 가게를 고친다.
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `owner_kakao_links`(0021 + init.sql) — 부분 유니크 셋. 그중 `uq_kakao_link_channel_key`
|
||||||
|
(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
|
||||||
|
- `services/kakao_link_service.py` — 발급·소비·조회·해제. 일회성은 코드 값이 아니라
|
||||||
|
`WHERE status='PENDING'` CAS 한 문장이 보장한다. 실패는 전부 같은 에러(`KAKAO_LINK_CODE_INVALID`)다 —
|
||||||
|
"없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다.
|
||||||
|
- 코드는 sha256 만 저장한다. 사장님이 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧
|
||||||
|
연결 권한을 갖는다. 글자에서 `0·O·1·I·L` 을 뺐다 — 잘못 읽어 실패하면 원인이 화면에 안 보인다.
|
||||||
|
- `router/v1/agent/kakao.py` 셋(`link`·`link/code`·`link/disconnect`), 전부 `no-store`/`no-referrer`.
|
||||||
|
- 프론트 `features/agent/` — `/sites` 의 Threads 카드 옆에 나란히. 연결은 사람 단위라 같은 자리다.
|
||||||
|
- `config/agent_config.py` 를 `social_config.py` 와 **일부러 갈랐다** — SNS 게재는 되돌릴 수 없는
|
||||||
|
대외 발화, 에이전트는 자기 사이트를 고치는 창구. 승인 강도도 보관하는 것도 다르다.
|
||||||
|
|
||||||
|
**★ 일부러 안 만든 것 — 코드 소비 엔드포인트**
|
||||||
|
코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다.
|
||||||
|
검증 없는 공개 소비 경로를 먼저 만들면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 —
|
||||||
|
이 표가 막으려던 바로 그 일이다. `redeem()` 은 서비스 함수로만 두고 라우터에 붙이지 않았다.
|
||||||
|
|
||||||
|
**검증** — `test_kakao_link.py` 15 passed. 전체 백엔드 `780 passed / 50 failed`인데,
|
||||||
|
그 50건은 **같은 커밋 이전(HEAD)에서도 동일하게 50건**이다(워크트리로 대조 확인) —
|
||||||
|
`test_gemini*`·`test_site_theme`·`test_search_console_service` 등 기존 이슈이고 이번 변경과 무관하다.
|
||||||
|
`npm run lint`(frontend·admin·site) 통과.
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- **"지금 생성하기"가 구간을 받는다**(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를
|
||||||
|
정해야하지 않을까" → "캘린더 UI로 날짜받게"). `POST .../post/generate?start=&end=`
|
||||||
|
(`blog_jobs.generate_range`) — 개별 생성과 같은 이유로 재고 상한(`REFILL_BELOW`)을 안 보고,
|
||||||
|
이미 글이 있는 날짜는 LLM 호출 없이 건너뛰고, 소재가 떨어지면 그 자리에서 멈춘다. 응답에
|
||||||
|
`requested`/`created` 를 같이 줘서 "N일 중 M일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는
|
||||||
|
버튼을 누르면 시작·끝일을 `<input type="date">` 두 개로 받는 다이얼로그가 뜬다.
|
||||||
|
- 기존 `blog_jobs.generate_now`(재고 상한 기반, "다음 빈 날부터 순서대로")는 삭제하고
|
||||||
|
`generate_range` 로 교체 — 호출부가 이 엔드포인트 하나뿐이라 하위호환 어댑터 없이 바로 바꿨다.
|
||||||
|
|
||||||
|
**실배포로 E2E 를 돌리다 잡은 버그 — `blog_service.generate_one` 의 죽은 import**
|
||||||
|
사장님이 "테스트하고 결과 알려줘"로 시켜서 로컬 docker 를 재배포하고 실제 API 로 전체 플로우를
|
||||||
|
돌렸더니(회원가입→사업장→발행 시드→생성→개별생성→승인), "지금 생성하기"가 500 으로 죽었다.
|
||||||
|
원인: `from services.external.gemini_text import DEFAULT_TEXT_MODEL, is_configured` —
|
||||||
|
`DEFAULT_TEXT_MODEL` 은 애초에 그 모듈에 있던 적이 없다(LLM 공급자를 gemini/openai 로 가르는
|
||||||
|
리팩터로 `services/external/gemini_text.py` 가 "소개문·FAQ 조립" 전용으로 바뀌면서, 모델
|
||||||
|
상수·`is_configured`는 `services/llm/gemini.py`(`DEFAULT_MODEL`)로 옮겨갔다). pytest 는 이
|
||||||
|
함수를 통째로 monkeypatch 하는 테스트뿐이라 이 import 자체가 실행된 적이 없어 26 passed 로도
|
||||||
|
안 잡혔다 — **"단위 테스트가 초록"과 "실제로 돈다"는 다른 것**이라는 걸 이번에 실측으로
|
||||||
|
확인했다. 고침: `services.llm.gemini` 에서 `DEFAULT_MODEL`·`is_configured` 를 가져오도록
|
||||||
|
import 한 줄만 수정.
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 27 passed(신규: 구간 생성 성공/거절).
|
||||||
|
전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·`test_search_console_service.py`
|
||||||
|
44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈, 앞선 라운드에서도 확인). `npm run
|
||||||
|
build -w @o2o/frontend` 통과. 로컬 docker 재배포 후 실제 API 로 회원가입→생성→개별생성→
|
||||||
|
구간생성→승인→BUILD 잡 큐잉까지 end-to-end 확인(진짜 Gemini 호출 포함, 브라우저 확장이
|
||||||
|
연결되지 않아 화면 클릭 대신 API 레벨로 돌렸다). → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 — 탭 3개→2개로 되돌림, 생성 이력에 모델명, 빈 날짜 개별 생성
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- **탭을 3개(이번 주·달력·생성 이력)에서 2개(블로그·생성 이력)로 되돌렸다.** 지난 라운드에서
|
||||||
|
카로셀·달력을 각자 탭으로 쪼갠 게 오독이었다(사장님 지시: "탭을 왜 이번주 달력 이렇게
|
||||||
|
나누고 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지") — 원래
|
||||||
|
요청은 "달력 위에 카로셀"이지 "카로셀 따로, 달력 따로"가 아니었다. 생성 이력만 별도 탭으로
|
||||||
|
남긴다(`BlogPostsPage.tsx` `Tab = 'main' | 'history'`).
|
||||||
|
- 달력 칸 배지 문구 "메일 발송됨" → **"발송완료"**(사장님 지시: "달력에 발송완료 된거는
|
||||||
|
되었다고 적으라고", `publishBadge`).
|
||||||
|
- **생성 이력에 어느 모델을 썼는지 추가**(사장님 지시: "생성이력도 상세하게 기록해놓으셈
|
||||||
|
어느 모델썼는지 등등"). 새 컬럼을 늘리는 대신 `place_posts.generation_meta`(jsonb) 한
|
||||||
|
칸에 `{"model": "..."}` 로 담는다(사장님 지시: "Jsonb 하나팟거 컬럼",
|
||||||
|
`migrations/0020_place_posts_generation_meta.sql`). `blog_service.generate_one()` 반환값을
|
||||||
|
`str | None` → `tuple[str, str] | None`(본문, 모델명)으로 바꾸고, `PostCRUD.generation_batches`
|
||||||
|
가 회차별 대표 모델(`MAX(generation_meta->>'model')`)을 같이 뽑는다.
|
||||||
|
- **빈 날짜 하나만 콕 집어 생성**(사장님 지시: "그리고 개별적으로 새로 만들수있게 해줘").
|
||||||
|
`POST /v1/place/{place_id}/post/generate-one?date=`(`PostService.generate_for_date` →
|
||||||
|
`blog_jobs.generate_one_for_date`) — 재고 상한(`REFILL_BELOW`)을 안 본다, 콕 집은 날짜라
|
||||||
|
상한이 끼어들 자리가 아니다. 프론트는 달력에서 **오늘 이후의 빈 칸**만 누르면 그 날짜로
|
||||||
|
요청하고, 성공하면 그 자리에서 모달을 연다(`Calendar` `onGenerateDay`/`generatingDay`).
|
||||||
|
지난 날짜 칸은 클릭을 막는다.
|
||||||
|
|
||||||
|
**밟은 함정 — ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다**
|
||||||
|
`PostCRUD.add_one`을 처음엔 ORM 객체(`place_posts(**row)`)를 그대로 돌려주게 짰다.
|
||||||
|
`execute_lambda_write`는 `func(s)` 실행 뒤 **commit까지 하고** 값을 돌려주므로,
|
||||||
|
호출측이 그 객체의 속성(`post_id` 등)을 읽는 시점엔 세션이 이미 끝나 `DetachedInstanceError`
|
||||||
|
가 날 자리였다. `post_id`·`status`(둘 다 Python 쪽 `default`)는 `flush()` 직후엔 이미
|
||||||
|
채워져 있으므로, **flush 직후 세션이 살아있을 때** 값만 plain dict 로 뽑아 돌려주게 고쳤다
|
||||||
|
— ORM 객체 자체를 세션 밖으로 내보내지 않는다.
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 26 passed(신규 3건: 개별 생성 성공·날짜
|
||||||
|
중복 실패·소유권 스코프). 전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·
|
||||||
|
`test_search_console_service.py` 44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈).
|
||||||
|
`npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 메일 — 승인 즉시 처리 + 수정 자동 로그인, 화면 탭 3개로
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- 메일 승인 링크: GET 이 확인 화면 없이 **즉시 승인**(`router/v1/site/post.py`). 메일
|
||||||
|
프리페치에 노출된다는 걸 알고도 사장님이 택한 것 — POST `/approve`, GET/POST
|
||||||
|
`/v1/site/post/edit`(공개 편집 화면) 전부 삭제, `PostService.edit` 도 같이 지웠다.
|
||||||
|
- 메일 수정 링크: 이제 **로그인 흐름**이다. `CreateDayPassToken`(그날 자정 KST 까지만
|
||||||
|
사는 접근 토큰, `router/v1/validator/dependencies.py`)을 실은
|
||||||
|
`/blog?placeId=&postId=&auto=` 로 간다. 빌더 앱이 그 토큰으로 로그인해 편집 모달을
|
||||||
|
바로 연다.
|
||||||
|
- **승인·수정 링크 둘 다 그날 자정(KST) 만료**로 통일(`blog_service.issue_token`, 예전
|
||||||
|
14일 → 자정). 그 뒤엔 로그인해서 빌더 앱에서 처리한다.
|
||||||
|
- 신규 엔드포인트: `GET .../post/{post_id}`(메일 수정 링크 전용 단건 조회),
|
||||||
|
`GET .../post/history`(생성 이력 — 언제 몇 건, 새 컬럼 없이 `created_at` 회차로 묶음).
|
||||||
|
- `BlogPostsPage.tsx` 를 탭 셋으로 재구성 — **이번 주 · 달력 · 생성 이력**. 카로셀 카드를
|
||||||
|
누르면 그 자리에서 고치는 대신 모달을 연다(미리보기용 `PostPreviewCard` 와 실제 편집용
|
||||||
|
`PostCard` 분리). 달력 칸엔 발행완료/발행실패에 **메일 발송됨** 배지를 추가했다(크론잡이
|
||||||
|
실제로 돌았다는 확인). 이전 달/월/다음 달을 달력 탭 안, 달력 바로 위로 옮겼다.
|
||||||
|
|
||||||
|
**밟은 함정 — 세션 복구보다 늦게 로그인시키면 이미 늦다**
|
||||||
|
`BlogPostsPage` 안에서 `auto` 토큰으로 로그인시켰더니 "메일온거 클릭했더니 로그인하라고
|
||||||
|
뜨는데?" — `RequireAuth` 는 라우트 렌더링 시점에 `isRestoring`/`user` 를 보고 그 자리에서
|
||||||
|
`/login` 으로 튕긴다. 페이지 컴포넌트는 그 판정 *이후에만* 마운트되므로, 컴포넌트 안의
|
||||||
|
`useEffect` 로 로그인시키는 건 이미 늦다. `auto` 파라미터 처리를 세션 복구
|
||||||
|
(`app/provider.tsx` `useRestoreSession`) 안으로 옮겨서 고쳤다 — JWT `sub` 클레임을
|
||||||
|
그대로 디코드해(`lib/jwt.ts`, 서명 검증은 이미 서버가 함) `useAuthStore` 를 채운다.
|
||||||
|
|
||||||
|
**밟은 함정 — raw SQL 로 timestamptz 에 naive UTC 를 바인딩하면 로컬 시간대로 샌다**
|
||||||
|
자정 만료로 정밀해지자 테스트 3개가 "이미 만료됨"으로 죽었다. 원인: 테스트 시더가
|
||||||
|
`text()` 로 `token_expires_at` 에 naive datetime(`GTime.UTC()` 류)을 직접 바인딩하는데,
|
||||||
|
컬럼 타입 정보가 없는 raw 바인딩은 asyncpg 가 **드라이버 프로세스의 로컬 시스템 시간대**로
|
||||||
|
해석한다 — 이 개발 머신은 KST(UTC+9) 라 9시간이 밀렸다. 예전엔 14일짜리 만료값이라 9시간
|
||||||
|
밀려도 부호가 안 바뀌어 안 드러났을 뿐이다. ORM 경로(`update()`/`insert()`)는 컬럼의
|
||||||
|
`DateTime(timezone=True)` 프로세서를 타서 이 문제가 없다 — 실제 운영 코드(`mark_sent`)는
|
||||||
|
전부 ORM 이라 안전했다. 고침: 테스트 시더에서 바인딩 직전에 `.replace(tzinfo=timezone.utc)`
|
||||||
|
로 명시(`tests/test_blog_post.py`). **raw text() 로 timestamptz 컬럼에 naive datetime 을
|
||||||
|
바인딩하는 코드를 다시 보면, 반드시 이 함정을 의심한다.**
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed. `npm run build -w
|
||||||
|
@o2o/frontend` 통과. mnchoi@o2o.kr 로 실제 메일 미리보기 발송 확인(가짜 place/post 라
|
||||||
|
링크 자체는 동작하지 않음, 형식만 확인). → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀은 일주일치·달력은 모달, scheduled_date NULL 백필
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `GET /v1/place/{place_id}/post/upcoming?days=7` 신설(`PostService.list_upcoming`) — 카로셀은
|
||||||
|
이제 브라우징 중인 달과 무관하게 **항상 오늘부터 7일치**만, 날짜 오름차순으로 본다.
|
||||||
|
기존 `list_for_place` CRUD 를 월 경계 대신 (오늘, 오늘+N) 경계로 그대로 재사용했다.
|
||||||
|
- 카로셀 카드에 배정일 전부 표시 + 오늘/내일 카드에 chip. 마우스 오버 시 z-index 를
|
||||||
|
최상단으로 올려 겹친 카드가 안 가리게 했다(`PostCarousel` hover 상태).
|
||||||
|
- 달력 칸 클릭이 "카로셀로 스크롤"에서 **모달**(`Dialog`, 기존 `components/ui/dialog.tsx`
|
||||||
|
재사용)로 바뀌었다 — 그 날짜의 글 전체 내용 + 수정·바로 발행 버튼을 그 자리에서 보여준다.
|
||||||
|
- 달력 이전/다음 달 이동을 **이번 달 ~ 1년 뒤**로 제한(`minMonth`/`maxMonth`, 문자열
|
||||||
|
비교로 버튼 비활성화). 그 밖의 달은 볼 이유가 없다(과거는 비어 있고, 미래는 아직
|
||||||
|
아무것도 배정 안 됨).
|
||||||
|
|
||||||
|
**밟은 함정 — `scheduled_date` NULL 백필**
|
||||||
|
배포 직후 사장님이 "지금 생성하기"로 실제 만든 글 13건이 화면에서 통째로 사라져 보였다.
|
||||||
|
원인: 그 글들은 `scheduled_date` 컬럼이 생기기 *전에* 만들어져 값이 비어 있었는데,
|
||||||
|
월별·주간 조회 둘 다 이제 `scheduled_date` 로 거르는 바람에 `IS NULL` 행이 조용히
|
||||||
|
빠졌다(SQL 에서 `NULL <= x` 는 항상 unknown). 실서버 DB 에 1회성 SQL 로 백필했다 —
|
||||||
|
업장별 `created_at` 순서를 살려 오늘부터 하루씩 순서대로 채움. 새 컬럼을 추가하는
|
||||||
|
마이그레이션은 앞으로도 **기존 행에 값이 없을 때 조회에서 조용히 빠지는지**를 먼저
|
||||||
|
따져야 한다.
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed(`upcoming` 엔드포인트 날짜
|
||||||
|
필터·정렬 회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀(편집) + 달력(발행완료/실패만 표시)
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `BlogPostsPage.tsx` 를 "리스트 + 달력 클릭 시 펼침" 구조에서 **카로셀(위) + 달력(아래)**
|
||||||
|
둘로 나눴다. 카로셀(`PostCarousel`)은 이 달 글 카드를 겹쳐 쌓아 가로로 넘기는 형태고,
|
||||||
|
편집·바로 발행 버튼은 이제 여기에만 있다. 달력(`Calendar`)은 보기 전용 — 칸마다 본문
|
||||||
|
앞부분 스니펫과 **발행완료/발행실패 배지만** 단다. 검수 대기·메일 발송 같은 발행 전
|
||||||
|
상태는 아무 배지도 안 단다. 칸을 누르면 카로셀의 해당 카드로 스크롤한다.
|
||||||
|
- `PostData` 에 `build_failed`(bool) 추가. `PostService._latest_build_failed` 가 그
|
||||||
|
업장의 가장 최근 BUILD 잡이 `JobStatus.DEAD` 인지 보고, APPROVED 인데 아직 안 나간
|
||||||
|
글에만 단다 — BUILD 잡 하나가 업장 승인분 전체를 한 번에 굽는 구조라 글 단위가 아니라
|
||||||
|
"이 업장 재발행이 막혀 있나" 를 보는 것이다.
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
사장님 지시: "위에 겹치는 카로셀로 글들의 카드가 보이는거고 밑에는 달력에 내용앞부분
|
||||||
|
약간이랑 발행되었는지 안되었는지 여부 이렇게 표시하면됨 발행전인건 표시하지 말고
|
||||||
|
발행완료/발행실패 이것만 표시하면 될듯" — 앞서 만든 "오늘 게재됨/검토 대기" 요약 카드
|
||||||
|
2장은 이 의도와 달랐다(집계 카드였지 개별 글 카로셀이 아니었다).
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 22 passed(발행실패 판정 회귀 테스트
|
||||||
|
2건 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 빌더 화면 — 달력 + 배정일(scheduled_date) + 즉시 생성·바로 발행
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `place_posts.scheduled_date`(date) 추가(`migrations/0019_place_posts_scheduled_date.sql`,
|
||||||
|
`init.sql`, `models.py`). `(place_id, scheduled_date)` 유니크 — 업장 하나가 같은 날짜를
|
||||||
|
두 번 못 쓴다. 생성 시 그 업장의 `MAX(scheduled_date)` 다음날(없으면 오늘, KST)부터 하루
|
||||||
|
한 건씩 순서대로 배정한다(`blog_jobs._generate_for_place`).
|
||||||
|
- `PostCRUD.due_for_mail` 이 이제 `scheduled_date <= 오늘` 인 것만 고른다 — 미래 배정 글이
|
||||||
|
그날 되기 전에 새는 것을 막는다. `list_for_place`(빌더 화면 월별 조회)도 `created_at` 대신
|
||||||
|
`scheduled_date` 기준으로 바꿨다.
|
||||||
|
- `BlogPostsPage.tsx` 를 리스트에서 **달력**으로 바꿨다 — 글이 0건이어도 달력 칸 자체는
|
||||||
|
항상 뜬다. 위에 **오늘 게재됨 · 검토 대기** 요약 카드 두 장을 살짝 겹쳐서 배치했다.
|
||||||
|
- **지금 생성하기**(`POST .../post/generate`) — 새벽 04:10 크론을 안 기다리고 그 자리에서
|
||||||
|
만든다. **바로 발행**(`POST .../post/{post_id}/approve`) — 안 고치고 그대로 승인.
|
||||||
|
- `SitesPage.tsx` 카드의 "더보기" 메뉴에 **디자인·컨텐츠 관리 / 미니블로그 관리 /
|
||||||
|
예약요청 관리** 세 항목을 얹었다(탭이 아니라 메뉴 — 사장님 지시). 예약요청은 아직 화면이
|
||||||
|
없다 — `booking_request.py` 가 요청을 DB 에 남기지 않기로 한 결정(2026-09-16)과 부딪혀서
|
||||||
|
안내만 띄운다.
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
사장님 요청: "포스트들이 다 날짜가 정해져야하는데" — `created_at`(만들어진 시각)만 있고
|
||||||
|
"언제 낼 것인가"가 없어서, 달력을 만들려면 화면이 근거 없는 날짜를 지어내야 했다. 또
|
||||||
|
"생성된 포스트가 없어도 달력은 계속 떠야지" — 목록이 비면 화면이 통째로 빈 상태 문구로
|
||||||
|
바뀌던 걸 고쳤다.
|
||||||
|
|
||||||
|
**밟은 함정** — `PostCRUD.due_for_mail`/`list_for_place` 시그니처가 바뀌어(`today`/날짜
|
||||||
|
경계 타입) 호출부를 같이 안 고치면 조용히 옛 컬럼을 봤을 것 — `_month_range` 를
|
||||||
|
UTC datetime 경계에서 KST 순수 date 경계로 바꿔 타임존 변환 자체를 없앴다(scheduled_date 는
|
||||||
|
timestamptz 가 아니라 DATE 라 변환이 필요 없다).
|
||||||
|
|
||||||
|
**검증** — `test_blog_post.py`·`test_blog_owner.py` 20 passed(배정일 순서·업장당 하루 한 통
|
||||||
|
회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과(typegen·tsc·eslint·vite build).
|
||||||
|
→ [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-17 — 미니 블로그 팀 사전검수 폐지 — 검수는 사장님이, 빌더 앱에 로그인 화면 추가
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `router/v1/site/blog_admin.py` · `services/blog_review_service.py` · `admin/frontend
|
||||||
|
BlogReviewPage` 삭제. 생성분은 금칙 필터(`is_publishable_body`)만 통과하면 곧장
|
||||||
|
`REVIEWED` 로 쌓여 팀 개입 없이 발송 대상이 된다(`blog_service.filter_drafts`).
|
||||||
|
- `blog_jobs.py` `BATCH_SIZE`·`REFILL_BELOW` 25/40 → 30/30(한 달치). `send_reviewed()` 가
|
||||||
|
`PostCRUD.due_for_mail`(`DISTINCT ON (place_id)`)을 써서 업장당 하루 한 통만 보낸다 —
|
||||||
|
전엔 전체 업장을 섞어 오래된 순으로 뽑아 밀린 업장이 하루에 두 통 이상 받을 수 있었다.
|
||||||
|
- 메일 확인 화면에 **수정해서 올리기** 버튼 추가. `GET/POST /v1/site/post/edit` 신설 —
|
||||||
|
저장하면 금칙 필터를 다시 타고, 통과하면 본문 갱신 + 그대로 승인.
|
||||||
|
- `router/v1/site/post.py` 에 `owner_router`(`/v1/place/{place_id}/post`) 신설 — 로그인
|
||||||
|
세션으로 이번 달 생성된 글을 보고, 메일이 아직 안 나간 `REVIEWED` 글도 바로 수정·승인.
|
||||||
|
`solution/frontend/src/pages/BlogPostsPage.tsx` + `SitesPage` 카드의 "관리" 메뉴에
|
||||||
|
진입점 추가.
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
2026-09-16 기획은 "팀이 먼저 거르고 사장님은 메일 클릭만" 이었는데, 다시 논의하면서 최종
|
||||||
|
판단을 사장님에게 넘기기로 했다 — 팀 검수 단계가 병목이고, 사장님이 자기 사이트 콘텐츠를
|
||||||
|
직접 못 보는 것도 이상했다.
|
||||||
|
|
||||||
|
**하는 김에 잡은 버그**
|
||||||
|
`services/post_service.py` 의 승인 처리가 BUILD 잡 payload 에 `owner_user_id` 를 안 채우고
|
||||||
|
있었다. `build_service.run_build:141` 은 `payload["owner_user_id"]` 를 무조건 읽으므로 —
|
||||||
|
**이메일 승인 클릭이 실제로는 사이트를 재발행하지 못하고 있었을 가능성이 높다**(잡은
|
||||||
|
큐에 들어가지만 워커가 돌릴 때 KeyError). `place_id` 로 `owner_user_id` 를 직접 조회해
|
||||||
|
채우도록 고쳤다. 회귀 테스트: `test_blog_post.py test_approve_enqueues_build_with_owner_user_id`.
|
||||||
|
|
||||||
|
**결과** — `solution/backend` 전체 pytest 784 passed(기존에도 실패하던 `search_console`
|
||||||
|
스케줄러 잡 개수 검증 2건은 이번 변경과 무관 — `blog-drafts`·`blog-mail` 상시 잡이 늘어난
|
||||||
|
탓, 별도 수정 필요). `tsc` 통과(solution/frontend · admin/frontend). → [MINI_BLOG.md](MINI_BLOG.md)
|
||||||
|
|
||||||
|
## 2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결
|
||||||
|
|
||||||
|
원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어
|
||||||
|
`PostCardToConversation` 호출마다 BadRequest. 플로우 재생성(웹훅 템플릿) + 채널로 지정해서
|
||||||
|
해결, 실제 채널 게시 확인함. `TEAMS_WEBHOOK_URL` 갱신함(`.env`, 커밋 안 됨).
|
||||||
|
|
||||||
|
## 2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다
|
||||||
|
|
||||||
|
`common/collect_diagnostics.py`(신규) + `collect_service.py` 채널별 실패 10곳 연결.
|
||||||
|
전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다.
|
||||||
|
|
||||||
|
**검증** — `python3 ast` 파싱, 수동 실행 확인.
|
||||||
|
|
||||||
|
## 2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `services/copy_service.py` — 소개문·FAQ 생성(`generate` 단계)에서 `GeminiError` 가 나면
|
||||||
|
잡을 실패시키지 않고 `generate` 를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다.
|
||||||
|
프론트 사유 라벨: `generationLabels.ts` `SKIP_REASONS.generation_failed`.
|
||||||
|
- `common/database/db_session_manager.py` — 유니크 제약 충돌(`IntegrityError`) 로그를
|
||||||
|
ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라
|
||||||
|
`services/collect_service.py` `_add_link` 가 이미 "이미 있으면 그만" 으로 처리한다.
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
API 키가 아예 없을 때는 이미 `generate` 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데
|
||||||
|
**호출이 실패할 때만** 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠
|
||||||
|
0건으로 막지 않고(`publish_gate.check_unique_content` — "얇은 콘텐츠로 발행을 막지 않기로
|
||||||
|
했다"), 다른 곁들이 콘텐츠(자작곡 등, `build_service.py`)도 실패하면 로그만 남기고 계속
|
||||||
|
진행한다 — 이 갈래만 예외였다.
|
||||||
|
|
||||||
|
실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를
|
||||||
|
쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다.
|
||||||
|
화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데
|
||||||
|
잡을 죽일 이유가 없었다.
|
||||||
|
|
||||||
|
유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속
|
||||||
|
오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그).
|
||||||
|
|
||||||
|
**남은 것** — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 ·
|
||||||
|
잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데,
|
||||||
|
그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다.
|
||||||
|
|
||||||
|
## 2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz
|
||||||
|
|
||||||
|
- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회,
|
||||||
|
job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이
|
||||||
|
기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다.
|
||||||
|
- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 **게이트 반려가
|
||||||
|
아닌** 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비
|
||||||
|
실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다.
|
||||||
|
- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py
|
||||||
|
와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다.
|
||||||
|
- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일).
|
||||||
|
- `/readyz` 추가 — `/healthz`(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다.
|
||||||
|
서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가
|
||||||
|
이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다).
|
||||||
|
- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at
|
||||||
|
을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert
|
||||||
|
직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다. `func.now()`(DB 쪽
|
||||||
|
시계)로 비교하도록 고쳤다.
|
||||||
|
- 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가,
|
||||||
|
16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py
|
||||||
|
(readyz 1건 추가, 2건) 전부 통과.
|
||||||
|
- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션
|
||||||
|
0016 서버 적용 — 전부 사용자 승인 후 별도 진행.
|
||||||
|
|
||||||
|
## 2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화
|
||||||
|
|
||||||
|
- `docker-compose.yml` `solution-site`(운영 진입점) 빌드에서 `VITE_AUTO_LOGIN_ID`·`PW`
|
||||||
|
build arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나
|
||||||
|
JS 에서 읽을 수 있었다. `nginx/Dockerfile` 도 그 ARG 자체를 안 받는다.
|
||||||
|
- `lib/autoSession.ts` 에 `import.meta.env.DEV` 가드를 더했다(둘째 안전판) — 운영 빌드는
|
||||||
|
이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로
|
||||||
|
운영 빌드를 돌려도 `build/client` 어디에도 그 문자열이 없는 것을 확인했다.
|
||||||
|
- `users.token_version`(마이그레이션 0015) 추가 — `refresh_token()` 이 지금까지 서명·만료만
|
||||||
|
보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지
|
||||||
|
계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고,
|
||||||
|
비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절).
|
||||||
|
- 검증: `tests/test_auth.py` 16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후
|
||||||
|
거절). `tests/test_schema_ddl.py` 통과(ORM ↔ init.sql 일치).
|
||||||
|
- 운영 미적용: 실제 서버 `.env` 의 `AUTO_LOGIN_ID`·`PW` 값 확인·제거와 마이그레이션 적용은
|
||||||
|
이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다.
|
||||||
|
|
||||||
|
## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기
|
||||||
|
|
||||||
|
- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다.
|
||||||
|
- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다.
|
||||||
|
- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다.
|
||||||
|
- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
|
||||||
|
- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다.
|
||||||
|
- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다.
|
||||||
|
- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과.
|
||||||
|
|
||||||
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
||||||
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-09-11 — 네이버에서 탐색될 준비 · `geo/` 모듈
|
## 2026-09-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다
|
||||||
|
|
||||||
**무슨 일** — AEO·SEO 는 발행물 쪽이 촘촘한데 **네이버 쪽은 창이 없었다.** 서치어드바이저
|
**추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서
|
||||||
소유확인이 안 붙어 있어서(이전 DEPLOY.md 2-2: "아직 안 붙였다") 사이트맵 제출·수집 요청·
|
변경본 648 passed / 2 failed, 변경 전 HEAD 사본 635 passed / 동일한 2 failed를 확인했다.
|
||||||
색인 진단을 아예 쓸 수 없고, 통보가 먹었는지 볼 방법도 없었다.
|
실패는 기존 `test_rate_limit_closes_the_tap`·썸네일 호스트 기대값 검사이며 SNS 신규 13건은 모두 통과했다.
|
||||||
|
공용 테스트 DB에서는 다른 실행의 삭제/정리와 충돌했으므로 그 결과는 회귀 판정에서 제외했다.
|
||||||
|
`npm run lint`·전체 프론트 빌드 통과, site vitest 62 passed.
|
||||||
|
임시 payload를 실제 프리렌더해 데스크톱·모바일 하단 카드를 확인했고, SNS 글만 있는 payload는
|
||||||
|
고유 콘텐츠 0건으로 발행 거부됨을 확인했다. 실제 Threads 게시·알림톡 발송·운영 배포는 실행하지 않았다.
|
||||||
|
운영 활성화 전제와 남은 정책은 [SOCIAL.md](SOCIAL.md)에 정리했다.
|
||||||
|
|
||||||
**점검하다 찾은 고장 — IndexNow 가 한 건도 안 나가고 있다.**
|
|
||||||
|
**무슨 일** — 발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고,
|
||||||
|
그건 검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로
|
||||||
|
짧은 글을 쓰고, 승인을 받아 **사장님 개인 계정**(스레드)으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.
|
||||||
|
|
||||||
|
**★ 이 변경의 크기** — 섹션 하나 추가가 아니다. 이 레포가 처음으로 ①외부에 **쓰기**를 하고
|
||||||
|
②**남의 계정 자격증명을 보관**하고 ③**되돌릴 수 없는 행위**를 한다. 아래 결정이 전부 여기서 나왔다.
|
||||||
|
|
||||||
|
**승인을 다시 둔다 — 7절의 예외** ([DECISIONS 7-1절](DECISIONS.md))
|
||||||
|
7절("LLM 이 쓴 문장은 승인 없이 나간다")의 "왜 안전한가" 두 줄이 여기서는 둘 다 성립하지 않는다.
|
||||||
|
기준은 문장의 참/거짓이 아니라 **명의**(사장님 계정의 발언) · **되돌릴 수 있나**(없다) ·
|
||||||
|
**무엇이 주로 틀리나**(문장이 아니라 링크 — `_publish_target` 이 계산하므로 앞 게이트가 못 본다)다.
|
||||||
|
7절의 함정은 구조로 막았다: 시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고,
|
||||||
|
승인 경로가 둘(알림톡·빌더)이며, 미승인은 만료되어 **화면에 보이게** 남는다.
|
||||||
|
|
||||||
|
**★ 게시는 주소가 확정된 사이트에만.** `sites.domain` 이 비면 발행 슬러그가 **상호명에서 파생**되고
|
||||||
|
(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다 — `SITE_SLUG_LOCKED` 는 `domain` 변경만
|
||||||
|
막으므로 여기엔 안 걸린다. 이미 올라간 글의 링크는 404 가 되고 **그 글은 수정할 수 없다.**
|
||||||
|
→ `PUBLISHED` + `current_version_id` + `domain` 셋이 다 있을 때만 허용한다.
|
||||||
|
|
||||||
|
**★ 승인은 GET 이 아니라 POST.** 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
|
||||||
|
연다. GET 승인이면 사장님이 안 눌렀는데 올라가고 로그에는 "승인됨" 으로 남는다.
|
||||||
|
일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
|
||||||
|
|
||||||
|
**게시는 기본으로 꺼져 있다**(`SOCIAL_POSTING_ENABLED=0`). 초안·승인까지는 계약 없이 돌지만
|
||||||
|
게시는 되돌릴 수 없어서, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다.
|
||||||
|
★ 1-4 가 이 기능의 **전제조건**이 됐다 — 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이
|
||||||
|
"죽은 링크 정책 미정" 이 된다.
|
||||||
|
|
||||||
|
**사진은 올리지 않는다.** 1-2(이미지 재게시)의 격리는 "나중에 필터로 뺄 수 있다" 는 전제 위에 있는데
|
||||||
|
SNS 는 그 전제가 깨진다(플랫폼 서버에 사본이 생긴다). 게다가 지금 OWNER 사진은 존재할 수 없다(5-3).
|
||||||
|
→ 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않았다.**
|
||||||
|
|
||||||
|
**플랫폼은 스레드다.** X 는 URL 이 든 글을 쓰는 데 **요청당 $0.20** 이 안내돼 있어(공식 가격표),
|
||||||
|
"계정 단위 고정비" 라는 처음 가정이 틀렸다 — 사이트마다 나가는 변동비다. 스레드는 직접 API 에
|
||||||
|
건당 과금 안내가 없다. 어댑터 경계는 그대로 두되 X 어댑터는 넣지 않았다([API_USAGE 5절](API_USAGE.md)).
|
||||||
|
|
||||||
|
**밟은 함정 둘**
|
||||||
|
- **ORM 기본값에 쉼표가 딸려 들어갔다.** `server_default=text("'[]',")` → `DEFAULT '[]', NOT NULL`
|
||||||
|
로 나가 **CREATE TABLE 이 통째로 실패**했다. 운영 DB 는 init.sql 로 만들어져 안 드러나고
|
||||||
|
**ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다 — 9월 10일의 `now()` 기본값 사고와 같은 자리다.
|
||||||
|
- **승인 스윕 주기가 1분이었다.** 쓰기 커넥션을 계속 집어 들어, 같은 컨테이너에서 도는 테스트가
|
||||||
|
커넥션을 못 받아 `TimeoutError` 로 무더기 실패했다(실측). 이 스윕이 하는 일은 "만료 표시" 와
|
||||||
|
"중단된 초안 정리" 뿐이라 분 단위 정밀도가 필요 없다 → **5분**.
|
||||||
|
|
||||||
|
**검증** — 백엔드 SNS 테스트 9건 통과(초안 dedup·owner 스코프 · 주소 고정 요구 · GET 프리페치가
|
||||||
|
상태를 안 바꾸는지 · 승인 CAS 일회성 · 만료·중단 스윕). `tsc -b`·`eslint` 통과(shared·site·frontend),
|
||||||
|
vitest 58 passed(신규 3). 스케줄러를 끈 상태에서 snapshot·vision·social 26건 동시 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측
|
||||||
|
|
||||||
|
- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.
|
||||||
|
- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다.
|
||||||
|
- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다.
|
||||||
|
- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행.
|
||||||
|
|
||||||
|
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
|
||||||
|
|
||||||
|
## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
|
||||||
|
|
||||||
|
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
|
||||||
|
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
|
||||||
|
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
|
||||||
|
- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md).
|
||||||
|
|
||||||
|
**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
|
||||||
|
|
||||||
|
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
|
||||||
|
발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고,
|
||||||
|
화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다.
|
||||||
|
|
||||||
|
**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted)
|
||||||
|
`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
|
||||||
|
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고
|
||||||
|
그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다.
|
||||||
|
|
||||||
|
→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
|
||||||
|
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
|
||||||
|
→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고,
|
||||||
|
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s/<slug>/img/` 에 두고 payload 주소를 바꾼다)
|
||||||
|
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
|
||||||
|
|
||||||
|
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서
|
||||||
|
겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다.
|
||||||
```
|
```
|
||||||
indexnow.py:52 <out>/s/<slug>/sitemap.xml 을 읽는다
|
생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분)
|
||||||
prerender.ts:369 "★ 사이트별 sitemap.xml 을 없앴다" — 더는 굽지 않는다
|
→ 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀
|
||||||
|
→ "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED)
|
||||||
```
|
```
|
||||||
사이트가 한 장이 되면서 사이트별 사이트맵을 루트 한 장으로 합쳤고, nginx 와
|
|
||||||
`check_search_ready.py` 는 따라갔는데 `indexnow.py` 만 안 따라갔다. 파일이 없으면
|
|
||||||
`site_urls()` 가 빈 목록을 돌려주고 경고 한 줄만 남긴 뒤 **발행 잡은 성공한다.**
|
|
||||||
네이버로 가는 유일한 자동 경로가 이것이다. 테스트는 `tmp_path` 에 사이트맵을 손으로 만들어
|
|
||||||
넣고 시작하므로(`test_indexnow.py:25`) 실물에 그 파일이 없다는 사실이 검증 범위 밖이었다.
|
|
||||||
→ 백엔드를 못 고치므로 **`geo` 가 알리기를 대신 맡는다**(`geo/naver/notify.py`).
|
|
||||||
필요한 게 "알릴 주소"와 "키" 둘뿐이고 **DB 가 필요 없어서** 가능하다. 주소는 **루트
|
|
||||||
사이트맵에서 읽는다** — 규칙을 또 만들면 사이트맵에 없는 URL 을 통보하게 되고 그건 404
|
|
||||||
통보다. ⚠️ **담당이 두 곳이 되면 안 된다**: 백엔드를 고치는 날 `GEO_NOTIFY_ENABLED=0`.
|
|
||||||
|
|
||||||
**발행 전/후를 가른 구조**
|
**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건).
|
||||||
|
|
||||||
|
**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면
|
||||||
|
업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 —
|
||||||
|
FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다.
|
||||||
|
|
||||||
|
**바꾼 곳**
|
||||||
|
- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외.
|
||||||
|
- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다.
|
||||||
|
- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4).
|
||||||
|
- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT).
|
||||||
|
`generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`).
|
||||||
|
init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다.
|
||||||
|
- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다.
|
||||||
|
- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가
|
||||||
|
(노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다).
|
||||||
|
- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만),
|
||||||
|
`run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다.
|
||||||
|
- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다.
|
||||||
|
- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시.
|
||||||
|
|
||||||
|
**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는
|
||||||
|
fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md).
|
||||||
|
|
||||||
|
**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침).
|
||||||
|
실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다.
|
||||||
|
site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed.
|
||||||
|
로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0.
|
||||||
|
0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다
|
||||||
|
|
||||||
|
**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
|
||||||
|
받고, 거른 결과를 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
|
||||||
```
|
```
|
||||||
preflight.py 발행 전 · 오리진 소유확인 · robots · 루트 사이트맵 · IndexNow 키 파일
|
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
|
||||||
postflight.py 발행 후 · 사이트 200 확인 → 알린다 → 성공분만 기록
|
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
|
||||||
watch.py 계속 루트 사이트맵 lastmod 변화 → 바뀐 것만 postflight
|
→ 거르기 → snapshot["seo"] → payload.seo
|
||||||
|
→ <title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
|
||||||
```
|
```
|
||||||
★★ **알리기를 발행 전에 하면 안 된다.** 없는 주소를 통보하면 404 를 받고, 그건 알리지 않은
|
|
||||||
것보다 나쁘다 — 헛주소를 보내는 호스트로 기록된다. 그래서 `postflight` 가 **200 을 확인한
|
|
||||||
뒤에만** 보낸다.
|
|
||||||
★ `watch.py` 는 **루트 사이트맵만 본다.** DB·잡 큐·볼륨을 안 들여다본다 — 크롤러가 발행을
|
|
||||||
알아채는 방식과 같아서 `solution` 을 고치지 않고 끼어들 수 있고, "밖에서 본다" 성격도 지킨다.
|
|
||||||
대가는 즉시성(기본 5분)이다.
|
|
||||||
|
|
||||||
**모듈 자리 — 최상단 `geo/`, 다만 백엔드 코드를 쓴다**
|
**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`·
|
||||||
처음 그은 경계는 **표 안 만듦 · 잡 큐 안 씀 · 모델 import 안 함** 이었다. 그 경계가 곧
|
`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과
|
||||||
기능을 막았다 — 소유확인 주기 재확인은 스케줄러가, 통보 재시도는 잡 큐가, 상태 추적은
|
일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다.
|
||||||
표가 필요하다. **밖에서 HTTP 만 보는 경계로는 점검까지만 된다.**
|
→ **키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다.
|
||||||
→ 폴더는 최상단에 두고(대상이 `solution`·`admin` 과 다르다), **의존만 허용했다**:
|
제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`.
|
||||||
`geo` → `solution/backend` 를 PYTHONPATH 로 얹는다. `admin/backend` 가 이미 같은 방식이다.
|
|
||||||
빌려 쓰는 것은 복제하면 조용히 틀리는 값 둘뿐이다 — 네이버 API **쿼터 카운터**(지역검색과
|
|
||||||
웹문서검색이 앱당 일 25,000회를 공유한다)와 `publish_origin()`(새 env 로 두면 canonical 과
|
|
||||||
갈린다).
|
|
||||||
→ 경계는 폴더가 아니라 **파일과 import 방향**으로 지킨다: `checks.py` 는 DB 를 보지 않고
|
|
||||||
세션을 인자로도 받지 않는다. 그리고 **`solution` 은 `geo` 를 import 하지 않는다** — 라우터·
|
|
||||||
워커 핸들러는 solution 의 등록표가 아니라 **진입점**이 마운트해야 순환이 안 생긴다.
|
|
||||||
|
|
||||||
**제약 — `solution/` 과 `admin/` 은 한 줄도 고치지 않는다.**
|
**★ SiteOntology 쪽 함정 (실측)**
|
||||||
import 는 자유롭지만 수정은 안 된다. 그래서 세 가지가 이상적인 자리에 없고, **대가를 적어
|
- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다
|
||||||
두고** 갔다(geo/README.md '제약' 절, 옮길 자리까지 명시).
|
→ 500 이면 지역 없이 한 번 더 보낸다.
|
||||||
- `NaverLocalClient.search_web()` 대신 `geo/naver/web_search.py` — ⚠️ **쿼터는 하나인데
|
- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved` 가
|
||||||
카운터가 둘이다.** 앱당 일 25,000회를 지역검색과 공유하는데 백엔드 `call_counts()` 에는
|
우리 place_id 가 아니면 버린다.
|
||||||
여기 호출이 안 들어간다. 합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다
|
|
||||||
- `COPY geo` 를 못 넣어 **컨테이너에서 안 돈다** — 레포 체크아웃 + 백엔드 venv 로만 돈다.
|
|
||||||
정기 실행이 필요해지면 `geo/Dockerfile` 로 자기 이미지를 갖는 것이 이 제약 아래서의 길이다
|
|
||||||
- 소유확인을 랜딩 메타태그(`solution/frontend/src/root.tsx`)로 심으려 했다가 **`nginx/site.conf`
|
|
||||||
가 파일을 내주는 방식**으로 옮겼다. ⚠️ 토큰이 두 곳에 산다(`site.conf` = 나가는 값,
|
|
||||||
루트 `.env` = 점검이 대조할 기대값 — nginx 가 env 를 못 읽는다). **어긋나면 점검이 잡는다.**
|
|
||||||
덤으로 얻은 것: `VITE_*` 가 아니라서 **프론트 재빌드가 없다**. 그래서 이 줄은 제약이
|
|
||||||
풀려도 되돌리는 게 이득인지 다시 따져야 한다
|
|
||||||
- 지역검색·`publish_origin`·설정 객체는 기존 코드를 **그대로 읽어 쓴다**(수정 없음)
|
|
||||||
|
|
||||||
**★★ 소유확인에서 가장 밟기 쉬운 것** — 오리진 루트의 `*.html` 은 nginx 맨 아래 `location /`
|
**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면
|
||||||
의 SPA 폴백으로 떨어져 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다(전용 블록이 있는 건
|
키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다.
|
||||||
`.txt` 뿐이다 — IndexNow 키). 파일을 올바로 올려도 검색엔진은 빌더 HTML 을 받고 "확인 실패"
|
|
||||||
만 뱉는다. 그래서 `location =` 블록이 필수이고, 점검은 **상태코드가 아니라 내용**으로 한다.
|
|
||||||
|
|
||||||
**한 일**
|
**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만,
|
||||||
- `geo/naver/checks.py`: 소유확인 메타태그 · Yeti 로 랜딩 · IndexNow 통보 URL 재현 ·
|
운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다.
|
||||||
웹문서 색인 · 스마트플레이스 역방향 링크. **`Finding` 목록만 돌려준다** — 라우터·워커 잡·
|
|
||||||
CLI 가 같은 함수를 쓰게. 화면에 찍는 일은 `geo/scripts/check_naver_eo.py` 가 한다.
|
|
||||||
`check_search_ready.py` 가 이미 보는 것(robots 의 Yeti, Yeti 의 발행본 접근)은 **다시
|
|
||||||
보지 않는다** — 한쪽만 고쳐지는 날이 온다.
|
|
||||||
- **라우터는 안 붙였다.** 어디서 쓸지는 화면을 만들 때 정한다. 붙일 때 권고와 주의는
|
|
||||||
[geo/README.md](../geo/README.md).
|
|
||||||
- `external/naver.py` 에 `search_web()` 추가. **같은 클라이언트에 둔 이유는 쿼터가 하나**라서다
|
|
||||||
— 지역검색과 웹문서검색이 앱당 일 25,000회를 공유하므로, 호출기를 따로 만들면 카운터가
|
|
||||||
갈려 아무도 정확히 못 센다. `_get` 에 `url`·`api` 를 받게 한 것이 그 때문이다.
|
|
||||||
- `nginx/site.conf.example` 에 소유확인 `location =` 블록(주석 처리 + 근거)과
|
|
||||||
`.env.example` 의 `NAVER_SITE_VERIFICATION`. `site.conf` 는 **바인드 마운트**라 재빌드 없이
|
|
||||||
reload 로 반영된다. ⚠️ 다만 그 파일은 git 에 없다 — **서버를 새로 세우면 다시 넣어야 하고**,
|
|
||||||
빼먹으면 며칠 뒤 소유확인이 조용히 풀린다. 절차는 [DEPLOY.md 2-2단계](DEPLOY.md).
|
|
||||||
|
|
||||||
**작업 중 잡은 것 (둘)**
|
---
|
||||||
- `NaverPlace` 의 필드는 `link` 가 아니라 `place_url` 이다. 응답 키 이름을
|
|
||||||
그대로 쓴 초안은 역방향 링크가 **항상 "없다"** 로 나왔다.
|
|
||||||
- `robots.txt` 그룹 경계를 빈 줄로만 끊었더니, 규칙 뒤에 붙은 `User-agent:` 가 앞 그룹에
|
|
||||||
합쳐져 **`*` 의 Disallow 가 Yeti 에도 적용됐다.** RFC 9309 상 규칙 뒤의 `User-agent` 는
|
|
||||||
새 그룹이다. 단위 테스트에서 "Yeti 가 전체 차단" 이 잘못 뜨면서 드러났다.
|
|
||||||
|
|
||||||
**안 한 것** — GEO 측정(Brand AEO B1~B5). 자리는 `geo/engines/` 이고 먼저 정할 것은
|
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
|
||||||
비용이다. 설계서 기본값 100문항×4엔진×3회 = 테넌트당 주 1,200회 호출로 사이트당 $1 상한과
|
|
||||||
정면으로 부딪친다([DEVELOPMENT_DIRECTION.md 3-2](DEVELOPMENT_DIRECTION.md)).
|
|
||||||
|
|
||||||
**검증** — 가짜 사이트맵·가짜 IndexNow 서버로 **7가지 시나리오**를 돌렸다:
|
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
||||||
slug 경계(`joy` 가 `joy-cafe` 를 안 끌고 온다) · dry-run(안 보낸다) · 실제 통보(본문의 host·
|
새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
|
||||||
keyLocation·urlList) · 첫 tick(전부 새 것) · 두 번째 tick(안 보낸다) · lastmod 변경분만 ·
|
|
||||||
**200 이 아니면 통보 안 함**. preflight 은 정상/고장 두 상태로. robots 판정은 단위로.
|
**흐름** — ★ **발행이 노래를 기다린다.**
|
||||||
그 밖에 문법·import 표면 확인. **실제 도메인 점검과 `pytest`·`tsc`·빌드는 못 돌렸다** —
|
```
|
||||||
이 클론에 `.venv`·`node_modules`·`.env` 가 없다.
|
발행 누름 → BUILD 잡
|
||||||
|
1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분)
|
||||||
|
3. mp3 를 out/songs/ 에 보관
|
||||||
|
4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다
|
||||||
|
프리렌더가 mp3 를 사이트 디렉토리로 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의
|
||||||
|
사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에
|
||||||
|
그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다.
|
||||||
|
★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면
|
||||||
|
노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다.
|
||||||
|
★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다.
|
||||||
|
|
||||||
|
**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다.
|
||||||
|
그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 —
|
||||||
|
사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다.
|
||||||
|
→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고,
|
||||||
|
Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다
|
||||||
|
(요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다).
|
||||||
|
★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는
|
||||||
|
없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다.
|
||||||
|
|
||||||
|
**★ Suno 주소를 그대로 싣지 않는다**
|
||||||
|
Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고
|
||||||
|
몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고
|
||||||
|
우리 경로(`/s/<slug>/<song_id>.mp3`)만 발행본에 내보낸다.
|
||||||
|
|
||||||
|
**★ 콜백이 아니라 폴링이다**
|
||||||
|
Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
|
||||||
|
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
|
||||||
|
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
|
||||||
|
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
|
||||||
|
|
||||||
|
**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
|
||||||
|
`out/songs/<song_id>.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다.
|
||||||
|
프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가
|
||||||
|
발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다.
|
||||||
|
|
||||||
|
**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** —
|
||||||
|
노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다.
|
||||||
|
자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다
|
||||||
|
(오디오 안의 말은 크롤러가 못 듣는다).
|
||||||
|
|
||||||
|
**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다).
|
||||||
|
상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면
|
||||||
|
직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md)
|
||||||
|
|
||||||
|
**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic
|
||||||
|
ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료.
|
||||||
|
`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인.
|
||||||
|
지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
46
docs/GENERATION_FLOW.md
Normal file
46
docs/GENERATION_FLOW.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# 콘텐츠 생성 · 진행 복구
|
||||||
|
|
||||||
|
2026-09-15. `builder?step=generating`은 COPY(소개문·FAQ) 작업이다.
|
||||||
|
사진 분석은 VISION, 정적 사이트·노래 생성은 발행 BUILD에 속한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
템플릿 선택 → POST /v1/place/{placeId}/copy → jobId를 URL에 기록
|
||||||
|
↓
|
||||||
|
새로고침 ──────────────────────→ GET /v1/job/{jobId}
|
||||||
|
↑
|
||||||
|
COPY 워커: prepare → generate → save → faq_fill
|
||||||
|
각 단계 진입·완료 → jobs.progress(JSONB)
|
||||||
|
↓
|
||||||
|
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor
|
||||||
|
```
|
||||||
|
|
||||||
|
| 책임 | 파일 |
|
||||||
|
|---|---|
|
||||||
|
| 실행 순서 | `solution/backend/services/copy_service.py` |
|
||||||
|
| 단계 구현 | `solution/backend/services/copy_steps.py` — `prepare_copy`, `generate_copy`, `save_copy`, `fill_faqs` |
|
||||||
|
| 프롬프트·응답 스키마 | `solution/backend/services/prompts/copy.py` |
|
||||||
|
| 모델 호출·생성물 검증 | `solution/backend/services/external/gemini_text.py` → `llm/gemini.py`, `grounding/copy.py` |
|
||||||
|
| 단계 기록 | `solution/backend/services/job_progress.py` → `crud/job_crud.py` |
|
||||||
|
| API 계약 | `solution/backend/router/v1/job/protocol.py` → OpenAPI → Orval |
|
||||||
|
| 조회·복구·완료 전환 | `solution/frontend/src/features/onboarding/useGenerationJob.ts` |
|
||||||
|
| 화면 / 문구 | 같은 폴더의 `Step5Generating.tsx` / `generationLabels.ts` |
|
||||||
|
|
||||||
|
- `jobs.status`는 작업 전체 상태, `progress.steps[].status`는 단계 상태다.
|
||||||
|
단계는 `pending/running/done/skipped/failed`. 시간으로 퍼센트나 단계를 올리지 않는다.
|
||||||
|
- `progress.attempt`는 워커 시도 번호다. 재시도는 단계를 처음부터 다시 기록한다.
|
||||||
|
기록은 실행 중인 워커·시도 번호·유효한 lease가 일치할 때만 허용한다.
|
||||||
|
- 새로고침은 GET만 한다. jobId가 없는 구 URL은 `POST copy {resume: true}`로
|
||||||
|
해당 사업장의 최근 COPY를 찾는다. DONE·DEAD도 반환하므로 완료됐다고 새 작업을 만들지 않는다.
|
||||||
|
권한 검사는 사업장 조회가 먼저 한다. URL로 조회하는 COPY도 소유자를 검사한다.
|
||||||
|
- 템플릿의 생성 버튼을 명시적으로 누르면 기본 POST로 새 작업을 요청한다.
|
||||||
|
같은 사업장의 활성 작업이 있으면 기존 중복 방지 규칙으로 그 작업에 연결한다.
|
||||||
|
- 통신 오류는 상태 재조회, DEAD는 이전 단계 또는 편집기로 직접 이동할 수 있다.
|
||||||
|
오류·대기·미설정 상태를 가짜 진행이나 완료 화면으로 바꾸지 않는다.
|
||||||
|
- 노래 단계는 이번 COPY 흐름에 추가하지 않았다. 발행 BUILD 진행 표시 확장은 별도다.
|
||||||
|
|
||||||
|
적용: 마이그레이션 `0013_job_progress.sql`을 먼저 적용한 뒤 API·워커·빌더를 배포한다.
|
||||||
|
기존 잡의 `progress`는 NULL이다. 이 경우 단계 목록을 지어내지 않고 전체 상태만 표시한다.
|
||||||
|
|
||||||
|
검증: `tests/test_copy_api.py`, `tests/test_job_queue.py`, `tests/test_schema_ddl.py`.
|
||||||
|
프론트는 개발 서버를 켜고 `node solution/frontend/tests/generation.mjs <개발 URL>` 실행.
|
||||||
|
브라우저 테스트는 모든 API를 가짜 응답으로 대체한다.
|
||||||
262
docs/MINI_BLOG.md
Normal file
262
docs/MINI_BLOG.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# 미니 블로그 — AI 자동 포스트 생성기 (2026-09-16 기획, 2026-09-17 검수 흐름 개편)
|
||||||
|
|
||||||
|
숙소 소개 아래에 붙는 짧은 글 게시판. 사장님에게 최종 결정권이 있다 — **팀 사전검수 단계는
|
||||||
|
없다.** 메일 링크는 여전히 로그인 없이 쓰고, 대신 빌더 앱에 로그인하면 이번 달 생성된 글
|
||||||
|
전체를 볼 수 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
스케줄러(한 달치 생성) → 금칙 필터(자동) → 메일 발송(업장당 하루 한 통, 승인·수정 두 링크)
|
||||||
|
→ 사장님이 승인(즉시 게재) / 수정(빌더 앱 자동 로그인 모달) → 재발행 → 정적 HTML에 글 추가
|
||||||
|
※ 두 링크 다 그날 자정(KST) 만료 — 그 뒤엔 로그인해서 빌더 앱에서 처리
|
||||||
|
|
||||||
|
(병행) 빌더 앱 로그인 → 블로그 글 화면(탭: 이번 주 · 달력 · 생성 이력) → 언제든 수정·승인
|
||||||
|
```
|
||||||
|
|
||||||
|
## 확정된 것
|
||||||
|
|
||||||
|
- 스테이 DB의 숙소 정보로 **140~150자** 홍보 문구를 AI가 만든다 (2026-09-16)
|
||||||
|
- **텍스트만.** 사진은 넣지 않는다 (2026-09-16)
|
||||||
|
- 숙소 소개 하단 **미니 블로그** 형식, 글이 쌓이면 **페이지 번호**로 넘긴다 (2026-09-16)
|
||||||
|
- 갈래를 나눠 생성하고 **이전에 다룬 주제와 중복되지 않게** 한다 (2026-09-16)
|
||||||
|
- ★ **팀 사전검수 폐지** — 검수는 사장님이 한다. 금칙 필터(자동)를 통과하면 바로 발송
|
||||||
|
대상이다 (2026-09-17)
|
||||||
|
- ★ **한 달치를 미리 쌓아 두고, 업장당 하루 한 통씩** 메일로 내보낸다 (2026-09-17)
|
||||||
|
- ★ 메일의 **승인** 링크는 로그인 없음(토큰이 신원) — 누르는 즉시 승인된다(2026-09-17,
|
||||||
|
사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). **수정** 링크는 반대로
|
||||||
|
로그인 흐름이다 — 그날짜리 자동 로그인 토큰을 실어 보내 빌더 앱의 편집 모달을 그대로
|
||||||
|
연다(2026-09-17, 사장님 지시: "수정하기는 해당 수정하기 페이지로 가게(모달) 로그인도
|
||||||
|
크레덴셜로 자동으로 되게"). **두 링크 다 그날 자정(KST) 만료**(2026-09-17, 사장님 지시:
|
||||||
|
"승인이랑 수정모두 자정에 만료") — 넘기면 로그인해서 빌더 앱에서 처리한다
|
||||||
|
- ★ 사장님이 문구를 **직접 고쳐서** 승인할 수 있다 — 메일의 수정 링크, 빌더 앱에서도 동일
|
||||||
|
(2026-09-17)
|
||||||
|
- ★ 글마다 **배정일(scheduled_date)** 이 있다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가
|
||||||
|
없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). 생성 시 그 업장의 다음
|
||||||
|
빈 날부터 하루 한 건씩 순서대로 배정한다
|
||||||
|
|
||||||
|
## 1. 데이터 — 표 하나
|
||||||
|
|
||||||
|
`postgres-init/init-data/init.sql` 과 `postgres-init/migrations/` **둘 다** 고친다.
|
||||||
|
|
||||||
|
| 칸 | 타입 | 무엇 |
|
||||||
|
|---|---|---|
|
||||||
|
| `post_id` | uuid pk | |
|
||||||
|
| `place_id` | uuid | 어느 업장 |
|
||||||
|
| `body` | varchar(400) | 본문 140~150자 |
|
||||||
|
| `topic_kind` | smallint | weather · festival · season · nearby · guide |
|
||||||
|
| `topic_key` | varchar(120) | 축제 id · 절기 · 장소 id — **중복 방지의 축** |
|
||||||
|
| `status` | smallint | DRAFT → REVIEWED → SENT → APPROVED → PUBLISHED / SKIPPED |
|
||||||
|
| `scheduled_date` | date | 이 업장 몫 배정일(KST). 하루 한 통 — 생성 시 순서대로 채운다 (2026-09-17) |
|
||||||
|
| `generation_meta` | jsonb | 생성 이력 상세 — 지금은 `{"model": "..."}` 하나뿐(사장님 지시: "생성이력도 상세하게
|
||||||
|
기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나팟거 컬럼", 새 컬럼을 안 늘리고 여기 얹는다, 2026-09-17) |
|
||||||
|
| `approve_token_hash` | varchar(64) | sha256. 평문은 메일에만 |
|
||||||
|
| `token_expires_at` | timestamptz | 발송 당일 자정(KST) — 수정 링크(day-pass)도 동일(2026-09-17, 이전엔 발송+14일) |
|
||||||
|
| `sent_at` · `approved_at` · `published_at` | timestamptz | |
|
||||||
|
| `published_version_id` | uuid | `site_versions` 참조 — 롤백 때 필요 |
|
||||||
|
|
||||||
|
유니크: `(place_id, topic_key)` — 같은 축제로 두 번 쓰지 않는다.
|
||||||
|
유니크: `(place_id, scheduled_date)` — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
|
||||||
|
|
||||||
|
## 2. 생성 — 스케줄러 잡
|
||||||
|
|
||||||
|
`scheduler/__init__.py` 에 `add_job` 한 줄, 로직은 `scheduler/jobs.py` → `services/blog_service.py`.
|
||||||
|
|
||||||
|
- **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다)
|
||||||
|
- **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 —
|
||||||
|
하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다
|
||||||
|
- **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다
|
||||||
|
(한 달치를 한 호출로 뽑으면 중복 검사가 안 된다)
|
||||||
|
- **배정일**: 그 업장의 `MAX(scheduled_date)` 다음날부터(없으면 오늘부터, KST) 하루 한 건씩
|
||||||
|
순서대로(`blog_jobs._next_scheduled_date`가 아니라 인라인 계산 — `_generate_for_place`).
|
||||||
|
"지금 생성하기"(수동 트리거)는 사장님이 직접 고른 구간을 채운다 — 같은 소재 선별·게이트
|
||||||
|
로직을 재사용하지만 배정일이 "다음날부터 자동"이 아니라 "그 구간"이다(`generate_range`,
|
||||||
|
7절)
|
||||||
|
- **갈래 분기**: 날씨·축제·계절·주변장소·이용안내. 갈래마다 프롬프트가 다르고,
|
||||||
|
근거가 되는 값도 다르다(날씨=`local.weather`, 축제=`local.festivals`, 주변=`local.attractions`)
|
||||||
|
- 프롬프트는 `shared/src/lib/section-prompts.ts` 규약을 따른다 → `npm run export:prompts`
|
||||||
|
|
||||||
|
### 게이트를 통과하는 문구만 만든다 — 팀 검수를 대신하는 자리
|
||||||
|
|
||||||
|
발행 게이트 규칙 1은 **미검증 fact 를 화면에 내지 않는 것**이다(`services/publish_gate.py`).
|
||||||
|
홍보 문구가 가격·시설·운영시간을 주장하면 그 주장을 뒷받침할 fact 가 없어 규칙과 부딪힌다.
|
||||||
|
★ 팀 사전검수가 없어진 지금, 이 필터가 유일한 자동 관문이다.
|
||||||
|
|
||||||
|
- 프롬프트에 금칙을 건다: 숫자로 된 가격·시간·인원·전화번호를 쓰지 않는다
|
||||||
|
- 생성 뒤 기계로 한 번 더 거른다(`blog_service.is_publishable_body`) — 통과하면 곧장
|
||||||
|
`REVIEWED` 로 쌓인다(사람이 올릴 필요 없음). 실패분은 로그만 남고 버려진다
|
||||||
|
- 통과한 문구는 **고유 콘텐츠**라 오히려 규칙 2(고유 콘텐츠 ≥ 1)에 보탬이 된다
|
||||||
|
- 수정 화면(메일·빌더 앱 공통)에서 사장님이 고친 본문도 저장 전에 **같은 필터**를 다시 탄다 —
|
||||||
|
로그인했다고 우회되지 않는다
|
||||||
|
|
||||||
|
## 3. 검수 — 사장님이 한다 (2026-09-17, 팀 사전검수 폐지)
|
||||||
|
|
||||||
|
★ `admin`(:9801)의 1차 검수 화면(`blog_admin.py`·`BlogReviewPage`)은 삭제했다. 최종 판단은
|
||||||
|
사장님 몫이고, 그 판단은 두 군데서 이뤄진다.
|
||||||
|
|
||||||
|
1. **메일** — 업장당 하루 한 통, 승인/수정/넘기기 (4·5절)
|
||||||
|
2. **빌더 앱 로그인** — 이번 달 생성된 글 전체를 미리 보고 메일이 오기 전에 바로
|
||||||
|
승인·수정할 수 있다 (7절 "빌더 앱 화면")
|
||||||
|
|
||||||
|
## 4. 발송 — 메일
|
||||||
|
|
||||||
|
`services/mail_service.py`(2026-09-16 완성, ACS 우선 · SMTP 폴백)를 그대로 쓴다.
|
||||||
|
|
||||||
|
- **업장당 하루 한 통.** `PostCRUD.due_for_mail` 이 `scheduled_date <= 오늘` 이면서
|
||||||
|
`DISTINCT ON (place_id)` 로 업장 하나가 밀려 있어도 그날은 가장 이른 배정일 한 통만
|
||||||
|
고른다(`blog_jobs.send_reviewed`) — 미래 배정일 글은 그날이 오기 전엔 안 나간다
|
||||||
|
- 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`)
|
||||||
|
- **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는
|
||||||
|
즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔
|
||||||
|
sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`). 승인 확인
|
||||||
|
화면은 그 업장의 발행된 사이트(미니 블로그 자리, `#blog`)로 5초 뒤 자동 이동한다
|
||||||
|
(2026-09-22 사장님 지시 — `router/v1/site/post.py _page`, `PostService._blog_url`).
|
||||||
|
재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다 —
|
||||||
|
그래도 "어디로 가면 보이는지"는 바로 알려준다. 발행된 사이트가 없으면 자동 이동 없이
|
||||||
|
확인 문구만 보여준다
|
||||||
|
- **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다.
|
||||||
|
`CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근
|
||||||
|
토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다
|
||||||
|
(`solution/frontend/src/app/provider.tsx` 세션 복구 단계에서 처리 — `BlogPostsPage` 안이
|
||||||
|
아니라 라우트 가드보다 먼저인 지점이어야 한다, 2026-09-17 실측: 늦게 처리하면
|
||||||
|
`RequireAuth` 가 이미 `/login` 으로 튕긴 뒤였다)
|
||||||
|
- 메일은 평문으로 흐른다 → 승인 링크로 할 수 있는 일은 **그 글 한 건의 게재**뿐이고,
|
||||||
|
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
|
||||||
|
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가
|
||||||
|
`place_id` 불일치를 걸러낸다)
|
||||||
|
|
||||||
|
## 5. 승인·수정
|
||||||
|
|
||||||
|
- **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해
|
||||||
|
"바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행
|
||||||
|
가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service.
|
||||||
|
PostService._approve_and_publish`). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다.
|
||||||
|
- **승인**: 이메일 GET 은 로그인 없이 즉시 승인, "바로 발행" 은 로그인 세션이 신원 →
|
||||||
|
둘 다 `status = APPROVED` → BUILD 잡 큐. 이메일 링크의 만료·재사용은 "처리할 수 없는
|
||||||
|
링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
|
||||||
|
- **쓰레드 연동**: 승인되는 순간(경로 무관) 그 업장이 쓰레드에 연결돼 있으면 같은 문구에
|
||||||
|
발행 링크를 붙여 쓰레드에도 즉시 게시한다(2026-09-21) — 별도 승인 없음(`docs/DECISIONS.md`
|
||||||
|
7-1-2 개정, `docs/SOCIAL.md`). 연동 안 돼 있거나 `SOCIAL_POSTING_ENABLED=0`이거나 사이트
|
||||||
|
domain이 미확정이면 조용히 건너뛴다. 실패해도 미니블로그 승인 자체는 막지 않는다
|
||||||
|
(`post_service.PostService._try_social_share`)
|
||||||
|
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문만
|
||||||
|
갱신한다. **승인 전환은 하지 않는다** — 실패하면 사유를 보여주고 다시 고치게 한다,
|
||||||
|
통과해도 두 승인 경로 중 하나를 눌러야 사이트에 반영된다
|
||||||
|
- **알림 이메일**: 승인 메일 수신자는 `places.notify_email`(비면 `users.email`) —
|
||||||
|
계정 로그인 이메일과 분리해서 업장별로 다른 담당자에게 보낼 수 있다(빌더 앱 미니블로그
|
||||||
|
관리 화면에서 수정, `PATCH /v1/place/{place_id}`)
|
||||||
|
- ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
|
||||||
|
가 `payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
|
||||||
|
세션과 달라 `post_service.PostService._enqueue_build` 가 `place_id` 로 직접 조회해
|
||||||
|
채운다. 이게 빠져 있던 게 2026-09-17 발견된 버그였다(회귀 테스트: `test_blog_post.py
|
||||||
|
test_approve_enqueues_build_with_owner_user_id`)
|
||||||
|
|
||||||
|
## 6. 게재 — 재발행
|
||||||
|
|
||||||
|
`docs/PUBLISH_VERSION.md` 의 파이프라인을 그대로 탄다. payload 에 `posts[]` 를 실어
|
||||||
|
**그 사이트 하나만** 다시 굽고 새 버전으로 링크를 전환한다. 전체 재굽기가 아니다.
|
||||||
|
|
||||||
|
⚠️ **발행일(`publishedAt`)이 움직인다.** 글 한 건 때문에 사이트 갱신일이 바뀌는 것이
|
||||||
|
맞는지 합의가 필요하다 — 색인에는 유리하지만 "사장님이 발행한 적 없는데 날짜가 바뀐다"는
|
||||||
|
기존 원칙과 부딪힌다.
|
||||||
|
|
||||||
|
## 7. 화면
|
||||||
|
|
||||||
|
### 발행된 사이트 — 미니 블로그
|
||||||
|
|
||||||
|
`solution/site/src/sections/BlogSection.tsx`, 숙소 소개(`intro`) 바로 아래.
|
||||||
|
|
||||||
|
- **글 전부가 HTML 안에 있고 JS 가 10건씩 보여준다.** 페이지를 눌렀을 때 더 불러오지 않는다 —
|
||||||
|
크롤러는 2페이지를 못 본다
|
||||||
|
- 사이트 하나 = 한 장 규칙은 유지한다. 주소를 늘리지 않는다
|
||||||
|
- 글이 100건을 넘으면 그때 별도 주소를 다시 논의한다
|
||||||
|
- 군산 읽기 전체 노출도 같은 페이지네이션을 쓴다 — 컴포넌트를 한 벌만 만든다
|
||||||
|
|
||||||
|
### 빌더 앱 — 이번 달 생성된 글 (2026-09-17)
|
||||||
|
|
||||||
|
`solution/frontend/src/pages/BlogPostsPage.tsx`. "내 사이트" 카드의 **관리 메뉴 →
|
||||||
|
미니블로그 관리**에서 `?placeId=` 를 들고 들어온다(전역 메뉴 하나로는 어느 사이트인지
|
||||||
|
못 고른다 — 사장님 한 명이 사이트 여럿을 가질 수 있다).
|
||||||
|
|
||||||
|
- 백엔드: `GET/PUT /v1/place/{place_id}/post`(`router/v1/site/post.py` `owner_router`,
|
||||||
|
:9800). 로그인 세션(`IsValidAccessToken`)이 신원이고, `PlaceCRUD.get_place` 로 소유권을
|
||||||
|
매번 확인한다 — 토큰 흐름과 인증 방식이 다를 뿐 편집 가드(`is_publishable_body`)는 같다
|
||||||
|
- **아직 메일이 안 나간 `REVIEWED` 글도 여기서 바로 승인·수정할 수 있다** —
|
||||||
|
`PostCRUD._EDITABLE = (SENT, REVIEWED)`. 메일을 기다릴 필요가 없다
|
||||||
|
- 월 단위 조회(`month=YYYY-MM`, 기본 이번 달, KST 기준) — `scheduled_date` 기준으로 그 달에
|
||||||
|
배정된 글을 가져온다
|
||||||
|
- **화면은 탭 둘뿐이다** (2026-09-17, 사장님 지시: "탭을 왜 이번주 달력 이렇게 나누고
|
||||||
|
지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지" — 카로셀·달력은
|
||||||
|
같은 화면에 **항상 같이** 뜬다, "생성 이력"만 별도 탭이다)
|
||||||
|
1. **블로그(카로셀 + 달력, 항상 같이 보인다).**
|
||||||
|
- **카로셀** — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
|
||||||
|
단위)과 무관하게 **항상 오늘부터 7일치**(`GET .../post/upcoming?days=7`,
|
||||||
|
`PostService.list_upcoming`, 날짜 오름차순). 카드가 겹쳐 쌓여 있고 가로로 넘기면
|
||||||
|
하나씩 앞으로 나온다(`PostCarousel`). 마우스를 올린 카드는 안 가려지게 z-index 를
|
||||||
|
맨 앞으로 올린다. 카드를 누르면 그 자리에서 고치는 게 아니라 **모달**을 연다(사장님
|
||||||
|
지시: "카드클릭해도 모달나와서 수정가능하게 해야지 왜 바로수정하게해") — 카드 자체는
|
||||||
|
미리보기(`PostPreviewCard`)뿐이고, 수정·바로 발행은 모달 안(`PostCard`)에서만 한다.
|
||||||
|
카드마다 배정일을 전부 쓰고, 오늘·내일인 카드에는 그 위에 "오늘"/"내일" chip 을 더 단다
|
||||||
|
- **달력** — **이전 달 · 월 · 다음 달** 이 달력 바로 위에 있다(사장님 지시). 이번 달부터
|
||||||
|
1년 뒤까지만 넘겨볼 수 있다(그 전·그 뒤는 볼 이유가 없다). 글이 0건이어도 칸은 항상
|
||||||
|
뜬다 — 배정일이 없으면 "이 달에 뭐가 있나"를 훑어볼 기준 자체가 없다. 칸마다 본문
|
||||||
|
앞부분 스니펫과 **발행완료 · 발행실패 · 발송완료 배지만** 보여준다 — 검수 대기처럼
|
||||||
|
아직 메일도 안 나간 상태는 아무 표시도 하지 않는다(사장님 지시: "발행전인건 표시하지
|
||||||
|
말고"), 메일 발송 여부는 크론잡이 실제로 돌았다는 확인이라 따로 보여준다(사장님 지시:
|
||||||
|
"달력에 발송완료 된거는 되었다고 적으라고"). **칸을 누르면 모달**로 그 글 전체 내용과
|
||||||
|
편집·발행 버튼을 보여준다
|
||||||
|
- **빈 날짜(오늘 이후만) 개별 생성** (2026-09-17, 사장님 지시: "그리고 개별적으로 새로
|
||||||
|
만들수있게 해줘") — 글이 없는 칸을 누르면 `POST .../post/generate-one?date=`
|
||||||
|
(`PostService.generate_for_date` → `blog_jobs.generate_one_for_date`)가 그 날짜 하나만
|
||||||
|
채운다. 재고 상한(`REFILL_BELOW`)을 안 본다 — 콕 집은 요청이라 상한이 끼어들 자리가
|
||||||
|
아니다. 이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.
|
||||||
|
지난 날짜는 만들 이유가 없어 클릭 자체를 막는다. 성공하면 그 자리에서 모달이 열린다
|
||||||
|
2. **생성 이력.** 언제 몇 건, 어느 모델로 만들었는지(사장님 지시: "생성이력도 있어야해
|
||||||
|
몇개 생성했는지" / "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등") —
|
||||||
|
`GET .../post/history`(`PostCRUD.generation_batches`). 새 컬럼 없이 기존 `created_at`
|
||||||
|
으로 회차를 묶는다(같은 트랜잭션 안의 `add_many` 는 DB `now()` 가 전부 같다). 모델명은
|
||||||
|
`generation_meta->>'model'` 의 대표값(`MAX`) 하나 — 한 회차 = 한 모델이 정상이다
|
||||||
|
- **발행실패 판정**: `PostService._latest_build_failed` — 그 업장의 가장 최근 BUILD 잡이
|
||||||
|
`JobStatus.DEAD`(재시도 소진)면, APPROVED 인데 아직 안 나간 글에 `build_failed=true` 를
|
||||||
|
단다. 글 단위가 아니라 "이 업장 재발행이 지금 막혀 있나" 를 보는 것이다 — BUILD 잡 하나가
|
||||||
|
그 업장의 승인분 전부를 한 번에 굽기 때문
|
||||||
|
- ⚠️ **`scheduled_date` 마이그레이션(0019) 전에 만들어진 글은 그 컬럼이 비어 있다.**
|
||||||
|
월별·주간 조회 둘 다 `scheduled_date` 로 거르므로, 비어 있으면 화면 어디에도 안 뜬다
|
||||||
|
(실측 2026-09-17: "지금 생성하기"로 만든 실제 글 13건이 이렇게 사라져 보였다). 배포
|
||||||
|
직후 한 번은 기존 NULL 행에 날짜를 채우는 백필이 필요하다 — 업장별로 `created_at` 순서를
|
||||||
|
살려 오늘부터 하루씩 순서대로 채운다(1회성, 스크립트로 남기지 않았다).
|
||||||
|
- **지금 생성하기** 버튼 — `POST /v1/place/{place_id}/post/generate?start=&end=`(사장님 지시:
|
||||||
|
"지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을
|
||||||
|
캘린더 입력(`<input type="date">`)으로 고르는 다이얼로그가 뜬다(사장님 지시: "캘린더
|
||||||
|
UI로 날짜받게"). 재고 상한(`REFILL_BELOW`)을 안 본다 — 개별 생성과 같은 이유로, 직접
|
||||||
|
고른 구간에 상한 로직이 끼어들 자리가 아니다(`blog_jobs.generate_range`). 이미 글이 있는
|
||||||
|
날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
|
||||||
|
`requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
|
||||||
|
보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
|
||||||
|
0건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 —
|
||||||
|
쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준)
|
||||||
|
|
||||||
|
## 8. 진행 (2026-09-17)
|
||||||
|
|
||||||
|
| | 자리 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| 표 + 마이그레이션 | `migrations/0017_place_posts.sql` · `init.sql` | 완료 |
|
||||||
|
| 생성 + 금칙 필터 | `services/blog_service.py` | 완료 |
|
||||||
|
| 생성·발송 스윕 | `services/blog_jobs.py` · `scheduler/jobs.py` | 완료 (새벽 4:10 생성 · 아침 9:00 발송, 업장당 하루 한 통) |
|
||||||
|
| ~~어드민 검수~~ | ~~`router/v1/site/blog_admin.py`~~ | **폐지(2026-09-17)** — 검수는 사장님이 한다 |
|
||||||
|
| 메일 + 승인·수정 | `services/mail_service.py` · `services/post_service.py` · `router/v1/site/post.py` | 완료 |
|
||||||
|
| 빌더 앱 로그인 화면(달력) | `router/v1/site/post.py owner_router` · `site/pages/BlogPostsPage.tsx` | 완료 |
|
||||||
|
| 배정일(scheduled_date) | `migrations/0019_*.sql` · `blog_jobs._generate_for_place` | 완료 |
|
||||||
|
| 생성 이력 상세(모델명, generation_meta) | `migrations/0020_*.sql` · `blog_service.generate_one` | 완료 |
|
||||||
|
| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
|
||||||
|
| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
|
||||||
|
| 재발행 연결 | `build_service` → `mark_published`, `owner_user_id` 버그 수정 | 완료 |
|
||||||
|
| 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 |
|
||||||
|
|
||||||
|
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
|
||||||
|
그리고 6절의 발행일 갱신 합의.
|
||||||
|
|
||||||
|
## 안 하는 것
|
||||||
|
|
||||||
|
- 사진 첨부 (2026-09-16 회의 확정)
|
||||||
|
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
|
||||||
|
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
|
||||||
|
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다
|
||||||
215
docs/NAVER_EO.md
215
docs/NAVER_EO.md
@ -1,215 +0,0 @@
|
|||||||
# NAVER_EO — 네이버에서 탐색되게 만들기 (조사와 설계)
|
|
||||||
|
|
||||||
> 조사일 2026-09-11. 구현 현황은 [geo/README.md](../geo/README.md),
|
|
||||||
> 제품 판단은 [PRODUCT.md](PRODUCT.md), 발행 절차는 [DEPLOY.md](DEPLOY.md).
|
|
||||||
|
|
||||||
**이 문서를 쓴 이유.** 우리 AEO·SEO 는 구글·AI 크롤러를 겨냥해 만들어졌다. 네이버는
|
|
||||||
구조가 달라서 같은 노력이 같은 결과를 내지 않는다. 무엇이 다르고, 그래서 **무엇을 목표로
|
|
||||||
잡아야 하는지**를 먼저 정한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 한 문장으로
|
|
||||||
|
|
||||||
> 네이버에서 우리 목표는 **"AI 답변에 인용되기"가 아니라, "플레이스와 공식 홈페이지가 한
|
|
||||||
> 업소로 묶이고 웹문서 검색에 잡히기"** 다.
|
|
||||||
|
|
||||||
[PRODUCT.md 1절](PRODUCT.md)이 말하는 "AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게"는
|
|
||||||
ChatGPT·Perplexity·Gemini 에는 그대로 통한다. **네이버에서는 경로가 다르다** — 아래 2절이 근거다.
|
|
||||||
이 차이를 모른 채 같은 전략을 밀면, 되지 않는 일에 시간을 쓰고 **될 일(플레이스 결합)을 놓친다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 조사 — 사실과 출처
|
|
||||||
|
|
||||||
★ **출처 성격을 같이 적는다.** 네이버는 랭킹 요소를 공개하지 않아서 업계 관측이 많이 섞인다.
|
|
||||||
관측을 공식처럼 인용하면 그 위에 쌓은 설계가 조용히 틀린다.
|
|
||||||
|
|
||||||
| # | 사실 | 성격 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 네이버 검색은 **통합검색 → 스마트블록(에어서치) → AI 브리핑** 층으로 답을 조립한다. 하나의 키워드를 의도 단위 블록으로 쪼갠다 | 공식 + 관측 |
|
|
||||||
| 2 | **AI 브리핑**이 생성형 AI 검색의 본류다. 2025-03 도입, 답변 근거로 쓴 **출처 콘텐츠를 함께 제시**한다. 실험 서비스 `Cue:` 는 **2026-04-09 종료** | 공식(보도) |
|
|
||||||
| 3 | ★★ **AI 브리핑의 출처는 네이버 생태계(블로그·카페·지식iN)와 뉴스·공식문서 비중이 높다** | 업계 관측 |
|
|
||||||
| 4 | AI 브리핑에 인용되는 조건으로 관측되는 것 넷: **정의형·Q&A 구조 · 경험 기반 수치 · 주제 세분화(주제당 1문서) · 신뢰 신호(작성자·자격, Schema 마크업)** | 업계 관측 |
|
|
||||||
| 5 | **C-Rank**(출처의 신뢰도) · **D.I.A / D.I.A+**(문서가 질의 의도에 얼마나 맞나)는 **블로그 중심** 알고리즘이다. 웹문서/사이트에 그대로 적용된다는 근거는 없다 | 관측 |
|
|
||||||
| 6 | 웹문서·사이트 노출은 **보장되지 않는다.** 콘텐츠 품질과 이용자 선호를 종합해 네이버가 판단한다 | 공식 |
|
|
||||||
| 7 | ★ **사이트명·사이트설명·Open Graph 제목·Open Graph 설명이 품질 판단 기준에 들어간다** | 공식 |
|
|
||||||
| 8 | **복사·붙여넣기한 내용은 "유사 문서"로 판단해 노출에서 제외**한다. 스팸이 아니어도 품질을 떨어뜨린다고 보면 노출되지 않는다 | 공식 |
|
|
||||||
| 9 | 신규 사이트는 **수집·노출까지 약 2~14일** | 공식 |
|
|
||||||
| 10 | **Yeti** 는 JS 영향도를 측정·해석하지만 **SSR 을 권장**한다. robots.txt 로 JS·CSS 리소스를 막으면 **그 페이지가 수집되지 않는다** | 공식 |
|
|
||||||
| 11 | 서치어드바이저 기능: **소유확인 · 수집 요청 · 사이트맵/RSS 제출 · 웹페이지 최적화 진단 · 노출·클릭 통계** | 공식 |
|
|
||||||
| 12 | 진단이 보는 필수 항목: **`<title>` · `meta description` · `<h1>` · 이미지 `alt`** (+ 프로토콜 불일치 내부링크, 접근 차단 리소스 등 10여 유형) | 공식 |
|
|
||||||
| 13 | 통계는 **최대 90일**, 플랫폼별 노출·클릭, **검색 키워드 top10 · 검색 문서 top10** | 공식 |
|
|
||||||
| 14 | 소유확인은 **HTML 파일 업로드 또는 `<head>` 메타태그**. DNS TXT 를 받지 않는다 | 공식 |
|
|
||||||
| 15 | **IndexNow 지원 (2023-07)** — 새 페이지·수정·삭제를 통보할 수 있다 | 공식 |
|
|
||||||
| 16 | 플레이스 순위는 **정보 충실도보다 행동 데이터**(저장·예약·주문·길찾기·리뷰·재방문)가 무겁다. **사업자 인증으로 등록한 업체가 우선**되는 경향 | 업계 관측 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 그래서 우리 제품에 무엇을 의미하나
|
|
||||||
|
|
||||||
### 2-1. ★ 가장 중요한 판단 — 네이버에서는 "인용"을 목표로 두지 않는다
|
|
||||||
|
|
||||||
조사 3·4 가 핵심이다. AI 브리핑은 **자기 생태계 문서를 우선 인용**하고, 인용 조건으로 관측된
|
|
||||||
것들(주제당 1문서, 경험 수치, 작성자 신뢰)은 **블로그 운영 전략**이다. 우리 산출물은 사장님
|
|
||||||
한 곳당 정적 한 장이고, 블로그를 운영하지 않는다.
|
|
||||||
|
|
||||||
→ **네이버 AI 브리핑 인용을 성공 기준으로 잡으면 안 된다.** 잡으면 두 가지가 따라온다:
|
|
||||||
① 되지 않는 일(생태계 밖 문서를 인용원으로 밀기)에 비용을 쓰고,
|
|
||||||
② "주제당 1문서" 를 따르려고 **[사이트 하나 = 한 장](ARCHITECTURE.md) 결정을 흔든다** —
|
|
||||||
그 결정은 페이지가 얇아지면 색인에서 버려진다는 근거로 내린 것이다(2026-08-31).
|
|
||||||
|
|
||||||
### 2-2. 네이버에서 우리가 실제로 가질 수 있는 자리 셋
|
|
||||||
|
|
||||||
| | 무엇 | 근거 | 지금 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **A. 웹문서 검색 노출** | "상호명" 질의에 공식 홈페이지가 잡힌다 | 조사 6·7·8·10 | 정적 HTML·OG 완비로 **조건은 이미 충족**. 등록이 안 돼 있다 |
|
|
||||||
| **B. 플레이스 ↔ 홈페이지 결합** | 스마트플레이스 "홈페이지" 칸이 우리 주소를 가리킨다 | 조사 16 | **비어 있다.** 사장님이 직접 넣어야 하고, 안내하는 자리가 없다 |
|
|
||||||
| **C. 공식 출처 지위** | 뉴스·공식문서 층에서 "그 업소의 1차 출처" 로 취급 | 조사 3·7 | 판단 근거가 약하다. 관측 대상 |
|
|
||||||
|
|
||||||
**A 와 B 가 이번 설계의 목표다.** C 는 A·B 가 서면 따라올 수 있는 것이지 직접 만들 수 없다.
|
|
||||||
|
|
||||||
### 2-3. 이미 하고 있어서 안 할 일
|
|
||||||
|
|
||||||
조사 7·8·10·12 가 요구하는 것은 **우리가 이미 다른 이유로 하고 있다.**
|
|
||||||
|
|
||||||
| 네이버가 보는 것 | 우리 쪽 이미 있는 자리 |
|
|
||||||
|---|---|
|
|
||||||
| SSR / 정적 HTML (조사 10) | 발행물이 정적 HTML — 제품 원칙 1번 |
|
|
||||||
| OG 제목·설명 (조사 7) | `seo/head.ts` 의 og:* + `og:locale ko_KR` |
|
|
||||||
| `<title>` · description · `h1` · `alt` (조사 12) | `seo/meta.ts`(길이 50~160자 보정) · 게이트가 alt 없는 사진을 안 싣는다 |
|
|
||||||
| 유사문서 회피 (조사 8) | **게이트 규칙 2 — 고유 콘텐츠 0건이면 발행 거부** |
|
|
||||||
| robots 로 JS·CSS 를 막지 않기 (조사 10) | `seo/robots.ts` 는 `Allow: /` 이고 앱 경로만 막는다 |
|
|
||||||
|
|
||||||
→ **네이버용으로 새로 만들 문서 최적화는 거의 없다.** 빈 것은 **등록·결합·측정**이다.
|
|
||||||
이게 이 조사의 결론이고, 아래 설계가 그 셋만 다루는 이유다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 설계 — 네 층, 순서가 곧 우선순위
|
|
||||||
|
|
||||||
### L0. 등록 (전제 — 이게 없으면 나머지가 관측 불가)
|
|
||||||
|
|
||||||
```
|
|
||||||
소유확인 → 사이트맵 제출 → 수집 요청 → 진단 확인 → 통계 열림
|
|
||||||
```
|
|
||||||
|
|
||||||
- 소유확인은 **메타태그 또는 HTML 파일**뿐이다(조사 14). 우리 구성에서의 함정과 절차는
|
|
||||||
[DEPLOY.md 2-2단계](DEPLOY.md) — 루트의 `*.html` 은 nginx SPA 폴백으로 떨어져 **404 가 아니라
|
|
||||||
빌더 앱 HTML 이 200 으로** 나간다
|
|
||||||
- ★ **등록 전에는 창이 아예 없다.** 사이트맵 제출·수집 요청·진단·통계가 전부 등록된 사이트에만
|
|
||||||
열린다. IndexNow 는 등록 없이도 동작하지만 **먹었는지 볼 방법이 없다**
|
|
||||||
- 오리진이 하나라 **루트에서 한 번** 하면 `/s/<slug>` 전부가 딸려온다
|
|
||||||
|
|
||||||
### L1. 문서 품질 — 진단 항목과 1:1 로 맞춘다
|
|
||||||
|
|
||||||
조사 12 의 필수 4항목은 우리가 이미 채운다(2-3절). 여기서 할 일은 **만드는 것이 아니라
|
|
||||||
어긋남을 잡는 것**이다. `geo/naver/checks.py` 가 보는 자리:
|
|
||||||
|
|
||||||
- `<title>` 존재·길이, `meta description` 존재·길이, `h1` 1개, 이미지 `alt` 누락 수
|
|
||||||
- 프로토콜 불일치 내부링크 — TLS 를 앞단 Apache 가 끊어 nginx `$scheme` 가 늘 `http` 인
|
|
||||||
이 구성에서 **실제로 밟을 수 있는 함정**이다([AGENTS.md](../AGENTS.md))
|
|
||||||
- ⚠️ **진단은 네이버가 자기 기준으로 다시 본다.** 우리 점검이 통과해도 진단이 지적할 수 있다 —
|
|
||||||
점검은 "명백히 빠진 것" 을 미리 잡는 것이고, 확정 판정은 서치어드바이저다
|
|
||||||
|
|
||||||
### L2. 엔티티 결합 — 여기가 네이버에서 가장 값어치 있는 자리
|
|
||||||
|
|
||||||
| | 방향 | 지금 |
|
|
||||||
|---|---|---|
|
|
||||||
| 우리 → 네이버 | `sameAs` 에 확정된 플레이스·예약 URL | **있다**(`seo/jsonld.ts`, 확정 채널만) |
|
|
||||||
| **네이버 → 우리** | 스마트플레이스 "홈페이지" 칸 = 발행본 주소 | ★ **없다. 이번 설계의 핵심 공백** |
|
|
||||||
| 값 일치 | 상호·주소·전화가 플레이스와 같아야 한다 | 수집이 플레이스에서 오므로 대개 같다. **틀어진 것을 잡는 점검이 없다** |
|
|
||||||
|
|
||||||
★ **역방향이 왜 중요한가.** 조사 16 에 따르면 플레이스는 사업자 인증 업체를 우선하고, 순위는
|
|
||||||
행동 데이터로 움직인다. **우리는 행동 데이터를 만들 수 없다.** 우리가 줄 수 있는 건
|
|
||||||
"이 업소의 공식 홈페이지가 여기다" 라는 **결합 신호 하나**이고, 그건 사장님이 스마트플레이스에
|
|
||||||
주소를 넣는 것으로만 생긴다. 비용 0, 우리가 통제 불가, 효과는 가장 큼 →
|
|
||||||
**제품이 해야 할 일은 "사장님이 그걸 하도록 만드는 것"** 이다(발행 완료 화면의 안내 한 줄).
|
|
||||||
|
|
||||||
### L3. AEO — 네이버판은 "인용" 이 아니라 "질문에 답하는 형태"만 가져온다
|
|
||||||
|
|
||||||
조사 4 의 넷 중 **우리 구조에 맞는 둘만** 취한다.
|
|
||||||
|
|
||||||
| 조건 | 취하나 | 왜 |
|
|
||||||
|---|---|---|
|
|
||||||
| 정의형·Q&A 구조 | **취한다** | 우리 FAQ·핵심정보 블록이 이미 그 형태다. `llms.txt` 도 같다 |
|
|
||||||
| 경험 기반 수치 | **취한다** | 확인된 fact(체크인 시각·주차 대수·요금)가 곧 수치다. **지어내지 않는다**는 규칙과 충돌하지 않는다 |
|
|
||||||
| 주제 세분화(주제당 1문서) | ❌ **안 한다** | "사이트 하나 = 한 장" 결정과 정면 충돌. 쪼개면 페이지가 얇아진다([ARCHITECTURE.md 5절](ARCHITECTURE.md)) |
|
|
||||||
| 작성자·자격 신뢰 신호 | **부분** | 사업자 정보·검증 시각은 있다. **개인 작성자 자격은 우리 제품에 없는 개념**이다 |
|
|
||||||
|
|
||||||
### L4. 측정 — 확정 창은 서치어드바이저 하나뿐이고 API 가 없다
|
|
||||||
|
|
||||||
| 무엇 | 어떻게 | 한계 |
|
|
||||||
|---|---|---|
|
|
||||||
| 확정 노출·클릭·키워드 top10 | 서치어드바이저 화면 | ★ **공개 API 가 없다.** 사람이 보는 수밖에 없다 |
|
|
||||||
| 색인 여부(근사) | **웹문서 검색 API**로 우리 호스트가 잡히나 | 통합검색 색인과 **같지 않다.** 잡히면 확실, 없으면 **미확정** |
|
|
||||||
| 역방향 링크 | **지역검색 API**의 `place_url` | 후보 5건·전화번호 없음 → 동명 업소 판별이 약하다. 단정하지 않는다 |
|
|
||||||
| 통보가 나갔나 | IndexNow 응답 + 통보 URL 존재 여부 | 먹었는지는 등록 후 서치어드바이저로만 |
|
|
||||||
|
|
||||||
⚠️ **쿼터.** 네이버 검색 API 는 앱당 **일 25,000회**이고 지역검색·웹문서검색이 **공유**한다.
|
|
||||||
사이트가 1,000개면 점검 1회에 2,000회다 — **전수 점검을 매일 돌릴 수 없다.**
|
|
||||||
→ 설계에 넣을 것: **표본 점검 + 변경분 우선**, 그리고 호출 예산을 코드가 알고 멈추는 상한.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 구현 계획 — 무엇을 언제
|
|
||||||
|
|
||||||
### 이미 있는 것 (`geo/naver/checks.py`)
|
|
||||||
|
|
||||||
소유확인 파일 · Yeti 로 랜딩 · IndexNow 통보 URL 재현 · 웹문서 색인(근사) · 역방향 링크.
|
|
||||||
|
|
||||||
### P1 — 지금 할 것 (제약 안에서 가능)
|
|
||||||
|
|
||||||
0. ~~알리기~~ — **완료.** `geo` 가 맡는다(발행 후 `postflight.py`, 자동은 `watch.py`)
|
|
||||||
1. **L1 진단 항목 점검 추가** — `<title>`·description·`h1`·`alt`·프로토콜 불일치 링크.
|
|
||||||
발행본 HTML 을 받아 세는 것뿐이라 외부 호출이 0이다
|
|
||||||
2. **값 일치 점검** — 플레이스의 상호·주소·전화 vs 발행본 JSON-LD. 지역검색 1회로 본다
|
|
||||||
3. **호출 예산** — 점검 1회당 네이버 API 상한을 코드가 알고 넘으면 멈춘다(위 쿼터)
|
|
||||||
|
|
||||||
### P2 — 제약이 풀려야 하는 것
|
|
||||||
|
|
||||||
| | 왜 막혀 있나 |
|
|
||||||
|---|---|
|
|
||||||
| ~~IndexNow 고장 수정~~ | **우회했다** — `geo/naver/notify.py` 가 루트 사이트맵을 읽어 대신 보낸다. 백엔드를 고치는 날 `GEO_NOTIFY_ENABLED=0` 으로 여기를 끈다 |
|
|
||||||
| 스마트플레이스 안내 UI | 발행 완료 화면은 `solution/frontend` 다. **L2 의 핵심 공백이 여기 걸려 있다** |
|
|
||||||
| 주기 재확인 잡 | `JobType`·`worker/handlers.py` 등록표가 백엔드에 있다. 우회하려면 `geo/Dockerfile` + 자기 스케줄러 |
|
|
||||||
| 점검 결과 저장·추이 | 표가 필요하고, **읽는 화면이 생긴 뒤에 만든다**([geo/README.md](../geo/README.md)) |
|
|
||||||
|
|
||||||
★ **P2 의 첫 줄이 가장 급하다.** L0~L2 를 다 해도 IndexNow 가 0건이면 네이버에 **알릴 통로가
|
|
||||||
없다** — 수집을 2~14일(조사 9) 기다리는 것과 즉시 통보의 차이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 아직 안 정한 것
|
|
||||||
|
|
||||||
- **L3 의 "주제 세분화" 를 영구히 거절할 것인가.** 지금은 거절이고 근거는 "한 장" 결정이다.
|
|
||||||
네이버 스마트블록에서 세분화가 실제로 얼마나 유리한지는 **우리 데이터로 측정한 적이 없다**
|
|
||||||
- **서치어드바이저 통계를 사람이 보는 것 말고 방법이 있나.** API 가 없다. 화면 캡처·수동 입력은
|
|
||||||
사람 손이 든다. 그럴 값어치가 있는지는 사이트 수가 늘어난 뒤 판단
|
|
||||||
- **네이버 AI 브리핑 인용이 정말 불가능한가.** 조사 3 은 **업계 관측**이다. 반증이 나오면
|
|
||||||
2-1 절을 다시 쓴다 — 그때 이 문서에 날짜와 함께 적는다
|
|
||||||
|
|
||||||
## 6. 하지 않는 것
|
|
||||||
|
|
||||||
| 안 한다 | 왜 |
|
|
||||||
|---|---|
|
|
||||||
| 블로그·카페 대량 발행으로 인용 노리기 | 유사문서 판정(조사 8) 대상이고, [PRODUCT.md 6절](PRODUCT.md) non-goal 이다 |
|
|
||||||
| 플레이스 행동 데이터 만들기(저장·리뷰 유도) | 어뷰징이다. 우리가 줄 것은 결합 신호뿐이다 |
|
|
||||||
| 네이버 검색창 긁어 순위 보기 | 봇 탐지 우회 영구 금지([DECISIONS.md](DECISIONS.md) 1-1). 공식 API 만 쓴다 |
|
|
||||||
| 키워드를 노린 문서 자동 증식 | 게이트 규칙 2 를 우회하는 짓이다. 게이트가 제품이다 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 출처
|
|
||||||
|
|
||||||
조사일 2026-09-11. 공식 문서(서치어드바이저·웹마스터 도구 안내)는 도구 화면 안에 있어 직접
|
|
||||||
링크가 어려운 것이 있고, 그 경우 이를 인용한 2차 자료를 적었다.
|
|
||||||
|
|
||||||
- 네이버 IndexNow 지원 — <https://news.hada.io/topic?id=19225>
|
|
||||||
- 네이버 AI 브리핑 · `Cue:` 종료 — <https://www.i-boss.co.kr/ab-2877-16898>
|
|
||||||
- AI 브리핑 인용 조건(관측) — <https://blog.oneplan.co.kr/naver-ai-search-optimization/>
|
|
||||||
- C-Rank · D.I.A(관측) — <https://locaposting.com/blog/naver-crank-dia-algorithm>
|
|
||||||
- 웹마스터 도구 노출 조건·품질 기준(공식 인용) — <https://www.imweb.me/faq?mode=view&category=29&category2=35&idx=623>
|
|
||||||
- 서치어드바이저 기능·진단 항목 — <https://www.interad.com/insights/naver-search-advisor-update>
|
|
||||||
- 소유확인·사이트맵 제출 절차 — <https://help.sixshop.com/learn-sixshop/store-manager/add-ons/naver-webmaster>
|
|
||||||
- 플레이스 순위 요소(관측) — <https://bbima.kr/blog/naver-place-ranking-2026>
|
|
||||||
@ -92,6 +92,8 @@
|
|||||||
|
|
||||||
## 8. 제약
|
## 8. 제약
|
||||||
|
|
||||||
|
비용은 사이트당 변동비·계정/계약당 고정비·일회성 개발비로 구분한다. SNS의 Gemini 생성·알림톡 건당 발송은 변동비다. Threads 직접 API에 공개 과금은 확인되지 않았다. 고정비를 사이트 생성 미터에 배분해 배치 크기에 따라 게이트 판정이 달라지게 하지 않는다. [API_USAGE 5절](API_USAGE.md#5-sns-비용-2026-09-14) 참조.
|
||||||
|
|
||||||
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
|
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
|
||||||
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
|
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
|
||||||
(★ 개발비와 섞지 말 것 — 그건 일회성이다)
|
(★ 개발비와 섞지 말 것 — 그건 일회성이다)
|
||||||
|
|||||||
32
docs/PUBLISH_VERSION.md
Normal file
32
docs/PUBLISH_VERSION.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 발행 버전과 워커 (2026-09-15)
|
||||||
|
|
||||||
|
이 문서가 이전 문서의 프리렌더 상시 기동·전체 재굽기 절차를 대체한다.
|
||||||
|
|
||||||
|
`BUILD → snapshot → payload → Node 렌더 → 결과 게이트 → 공개 링크 전환 → DB 기록`
|
||||||
|
|
||||||
|
- Python은 HTML을 만들지 않는다. 미리 컴파일된 Node를 실행하고 JSON 보고서만 읽는다.
|
||||||
|
- 워커 이미지에 Node와 렌더러를 포함한다. 실행 중 npm 설치·번들 빌드는 없다.
|
||||||
|
- 공유 볼륨의 파일 잠금으로 렌더·공개 전환을 직렬화한다. 수집 등 다른 잡은 동시 실행한다.
|
||||||
|
- `out/versions/<slug>/<version>`에 성공한 HTML과 렌더 보고서를 보존한다.
|
||||||
|
- `out/s/<slug>`는 공개 버전의 상대 심볼릭 링크다. 게이트 통과 후 전환한다.
|
||||||
|
- 성공한 버전은 재시도·롤백 때 다시 쓰지 않는다. 기존 일반 디렉토리는 첫 재발행 때 legacy로 보존한다.
|
||||||
|
- 배포 시 HTML·기존 HTML의 자산 주소를 수정하지 않는다. 공용 자산과 미리보기 셸만 준비한다.
|
||||||
|
- 목업과 보관 버전의 참조 자산도 삭제 대상에서 제외한다. versions는 Azure 공용 업로드에서 제외한다.
|
||||||
|
- 롤백 API: `POST /v1/place/{place_id}/site/version/rollback`, `target_version`. 소유권 검사와 BUILD 중복 방지 키를 공유한다.
|
||||||
|
- 로컬 공개 전환과 DB/Azure는 단일 트랜잭션이 아니다. 외부 저장소·DB 실패 시 재시도 및 운영 확인이 필요하다.
|
||||||
|
- 최초 일반 디렉토리→링크 전환은 두 rename 사이 짧은 공백이 가능하다. 이후 링크 교체는 원자적이다.
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
1. 진행 중 잡·서버 변경·목업 및 참조 자산 해시를 확인하고 site-out을 백업한다.
|
||||||
|
2. backend·worker·site 이미지를 빌드한다. admin은 기본 대상이 아니다.
|
||||||
|
3. 기존 worker와 solution-prerender를 중지한 뒤 새 worker를 기동한다. 두 렌더러를 동시에 실행하지 않는다.
|
||||||
|
4. API·미리보기·테스트 발행을 확인하고 목업 해시를 대조한다. 전체 재굽기·republish_all은 실행하지 않는다.
|
||||||
|
|
||||||
|
워커 경로: `SITE_PAYLOAD_DIR=/app/solution/site/payloads`, `SITE_OUTPUT_DIR=/app/solution/site/out`.
|
||||||
|
DB 테이블 추가는 없다. 기존 버전·잡·발행 로그를 사용한다.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
예약 전 확인과 요약을 이용안내 및 예약에 통합한다. 별도 요약 섹션과 예약 카드의 중복 규정은 제거한다.
|
||||||
|
빌더 미리보기는 iframe 내부 렌더 완료 신호까지 스피너를 표시한다. 출처·iframe을 확인하고 12초 상한을 둔다.
|
||||||
99
docs/SEARCH_CONSOLE.md
Normal file
99
docs/SEARCH_CONSOLE.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Google Search Console 자동 추적
|
||||||
|
|
||||||
|
`발행 DB 감지 → 공개 사이트맵 확인/제출 → 색인 조회 → 상태 저장·Teams 알림`
|
||||||
|
|
||||||
|
## 경계
|
||||||
|
|
||||||
|
- 기존 API의 스케줄러에서 10분마다 실행한다. 컨테이너 추가 없음.
|
||||||
|
- `sites.status=PUBLISHED`인 사이트만 등록하므로 초안/목업 디렉토리 나열을 작업 원장으로 쓰지 않는다.
|
||||||
|
- 발행 DB에서 재발견한다. 발행 순간 별도 큐 적재가 실패하는 틈이 없고 재시작해도 이어진다.
|
||||||
|
- 발행 트랜잭션/잡과 독립적이다. Google 실패가 사이트 발행을 실패로 바꾸지 않는다.
|
||||||
|
- 한 번에 신규 발행 100개 등록, 조회는 오래 기다린 5개 처리. 정상 조회는 24시간 후 반복.
|
||||||
|
- 현재 렌더러의 단일 루트 urlset만 지원하고 읽기 상한은 5MB다. 향후 sitemap index 분할 시 확장한다.
|
||||||
|
- 오류는 1·2·4·8·16·24시간 간격 재시도. 기본 주기 기준 하루 최대 720회 검사이며,
|
||||||
|
다른 도구의 같은 속성 사용량도 Google 할당량에 포함된다. 대량 백로그는 여러 날에 걸쳐 소진한다.
|
||||||
|
- PostgreSQL transaction advisory lock으로 다중 API 프로세스의 동시 배치를 막는다.
|
||||||
|
단일 배치는 외부 호출 동안 트랜잭션/연결 1개를 점유한다(검사 1건 최대 90초, 최대 5건).
|
||||||
|
- 사이트맵 제출 성공과 URL 색인 성공은 별개다. `first_indexed_at`은 **우리가 처음 PASS를 관측한 시각**이다.
|
||||||
|
Google 내부 색인 시각이나 최신 발행 버전 반영 시각이 아니다. 원본 `lastCrawlTime`도 함께 보관한다.
|
||||||
|
- 재발행 시 해당 발행의 관측 상태를 초기화한다. 지난 관측 이력 전체를 누적하는 이벤트 저장소는 아니다.
|
||||||
|
- `SITE_PUBLIC_HOST` 변경은 기존 지침대로 재발행이 필요하다. 사이트 주소의 단일 출처는 `site_payload`다.
|
||||||
|
|
||||||
|
## 최초 설정 (운영자)
|
||||||
|
|
||||||
|
1. Search Console에서 발행 도메인의 소유권 확인. URL-prefix 속성이면
|
||||||
|
`https://web4ai.o2osolution.ai/`, 도메인 속성이면 `sc-domain:web4ai.o2osolution.ai` 형태.
|
||||||
|
2. Google Cloud에서 Search Console API 활성화, 전용 서비스 계정 생성.
|
||||||
|
3. Search Console 속성 설정 → 사용자 및 권한에서 그 서비스 계정 이메일에 전체 사용자 권한 부여.
|
||||||
|
Google 로그인용 `GOOGLE_CLIENT_ID`와는 다른 인증이다.
|
||||||
|
4. 서비스 계정 JSON 키는 **저장소 밖**에 보관한다. 권한을 최소화하고 git/이미지/로그에 넣지 않는다.
|
||||||
|
5. 루트 `.env` 설정:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
GSC_ENABLED=1
|
||||||
|
GSC_PROPERTY_URL=https://web4ai.o2osolution.ai/
|
||||||
|
GSC_CREDENTIALS_HOST_FILE=/secure/location/search-console.json
|
||||||
|
GSC_ALERT_DAYS=7
|
||||||
|
GSC_ALERT_WEBHOOK_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
키 생성/권한 부여/실제 알림 전송은 구현 검증 중 자동 수행하지 않는다.
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
먼저 새 이미지에 requirements를 설치하고 `0014_search_console.sql`을 기존 마이그레이션 도구로 적용한다.
|
||||||
|
프로젝트 전체 마이그레이션 순서를 확인한 뒤 실행한다. 아래는 운영자가 실행할 명령이며 자동 배포하지 않았다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T solution-backend python scripts/migrate.py
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d --build solution-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
선택 compose 파일은 API에만 키를 읽기 전용 마운트하고 `GSC_CREDENTIALS_FILE`을 설정한다.
|
||||||
|
없는 파일을 디렉토리로 자동 생성하지 않는다. 이후 배포에서도 이 override를 함께 사용해야 한다.
|
||||||
|
로컬 Python 실행은 `GSC_CREDENTIALS_FILE`에 로컬 키 파일 경로를 지정한다.
|
||||||
|
켜진 스케줄러는 첫 10분 주기부터 기존 발행 사이트도 등록한다. `GSC_ENABLED=0`이면 DB/Google 호출 모두 생략한다.
|
||||||
|
|
||||||
|
## 알림
|
||||||
|
|
||||||
|
Teams Workflows의 webhook 수신 → 채널에 Adaptive Card 게시 흐름 URL을
|
||||||
|
`GSC_ALERT_WEBHOOK_URL`에 넣는다. 비우면 외부 전송 없이 경고 로그/DB만 남는다.
|
||||||
|
API/사이트맵 오류 또는 발행 후 기본 7일 미색인 시 알린다. 성공한 알림은 사이트별 24시간 중복 억제.
|
||||||
|
전송 실패는 `alerted_at`을 갱신하지 않아 다음 검사 때 재시도한다.
|
||||||
|
외부 전송 후 DB commit 전에 죽으면 중복 알림이 가능하다(at-least-once).
|
||||||
|
키·토큰·webhook URL·Google 오류 본문은 알림에 포함하지 않는다.
|
||||||
|
|
||||||
|
## 결과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T solution-backend python scripts/search_console_status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
읽기 전용이며 Google API를 추가 호출하지 않는다. 프론트 화면/API 계약은 변경하지 않았다.
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|---|---|
|
||||||
|
| `services/search_console_client.py` | 인증·Google HTTP·오류 정규화 |
|
||||||
|
| `services/search_console_settings.py` | 선택 설정·속성 URL 범위 |
|
||||||
|
| `services/search_console_service.py` | 배치 흐름·재시도·관측 결과 |
|
||||||
|
| `crud/search_console_crud.py` | 발행 감지·등록·조회 순서·동시 실행 잠금 |
|
||||||
|
| `services/search_console_alerts.py` | 알림 조건·Teams 전송 |
|
||||||
|
|
||||||
|
## 구글 지원 범위 / 남은 운영 작업
|
||||||
|
|
||||||
|
- [사이트맵 제출 API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)는 지원된다.
|
||||||
|
- [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)는
|
||||||
|
Google이 이미 알고 있는 상태 조회용이며 실시간 페이지 테스트나 색인 요청 API가 아니다.
|
||||||
|
- 일반 숙박 사이트는 [Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api) 대상이 아니다.
|
||||||
|
- [검사 할당량](https://developers.google.com/webmaster-tools/limits)은 속성당 하루 2,000회다.
|
||||||
|
- [Teams webhook 형식](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook).
|
||||||
|
- 실제 서비스 계정 권한/사이트맵 제출/색인 관측/Teams 수신은 설정 후 운영 검증이 필요하다.
|
||||||
|
- 기존 루트 사이트맵의 백업 URL 정리와 IndexNow 개별 사이트맵 참조 문제는 이 기능과 별도다.
|
||||||
|
이 기능은 기존 공개 사이트맵을 제출하며 내용을 다시 만들거나 목업을 삭제하지 않는다.
|
||||||
|
|
||||||
|
## 구현 검증 (2026-09-15)
|
||||||
|
|
||||||
|
- 격리 PostgreSQL에서 클라이언트·배치·스키마·IndexNow 관련 59건 통과.
|
||||||
|
- 발행·설정·사이트 목록 회귀검사: 23건 통과, `test_unverified_fact_blocks_publish` 1건 실패.
|
||||||
|
해당 실패는 변경 전 HEAD `9773bc0`의 발행 코드에서도 동일 재현됨(GSC 비활성).
|
||||||
|
- Google/Teams 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상.
|
||||||
71
docs/SEARCH_CONSOLE_CLIENT.md
Normal file
71
docs/SEARCH_CONSOLE_CLIENT.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Search Console 클라이언트
|
||||||
|
|
||||||
|
`solution/backend/services/search_console_client.py` — Google Search Console 에
|
||||||
|
사이트맵을 제출하고 URL 색인 상태를 조회하는 REST 클라이언트만 다룬다.
|
||||||
|
DB 저장·스케줄링·발행 감지·환경 설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)를 따른다.
|
||||||
|
|
||||||
|
공식 문서: [Sitemaps.submit](https://developers.google.com/webmaster-tools/v1/sitemaps/submit) ·
|
||||||
|
[urlInspection.index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)
|
||||||
|
|
||||||
|
## 1. 계약
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SearchConsoleClient:
|
||||||
|
def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): ...
|
||||||
|
async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: ...
|
||||||
|
async def inspect_url(self, property_url: str, page_url: str) -> dict: ... # indexStatusResult 만
|
||||||
|
async def aclose(self) -> None: ...
|
||||||
|
# async with SearchConsoleClient(...) as client: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `credentials_file`: 서비스 계정 JSON 키 파일 경로.
|
||||||
|
- `transport`: 테스트에서 `httpx.MockTransport` 를 꽂는 자리 — 실제 Google 호출 없이 검증한다.
|
||||||
|
- `inspect_url` 은 응답의 `inspectionResult.indexStatusResult` 만 돌려준다. 그 경로가
|
||||||
|
없거나(검사 실패) 빈 dict 면(실제 검사가 안 된 응답) `SearchConsoleError` 를 올린다 —
|
||||||
|
**"미색인"으로 넘겨짚지 않는다.**
|
||||||
|
|
||||||
|
## 2. 인증
|
||||||
|
|
||||||
|
서비스 계정 JSON 키 파일 + scope `https://www.googleapis.com/auth/webmasters`.
|
||||||
|
`google.oauth2.service_account` · `google.auth.transport.requests.Request` · `requests` 는
|
||||||
|
전부 함수 안에서 import 한다. 두 패키지는 백엔드 `requirements.txt`에 포함되어 있다.
|
||||||
|
|
||||||
|
- 토큰은 클라이언트 인스턴스에 캐시된다(`credentials.valid` 인 동안 재사용, 매 호출
|
||||||
|
갱신하지 않는다). 동시 호출은 `asyncio.Lock` 으로 갱신을 한 번만 태운다.
|
||||||
|
- 갱신은 `asyncio.to_thread` 로 별도 스레드에서 돈다. 내부 `requests.Session` 요청에는
|
||||||
|
타임아웃을 강제로 20초로 덮어씌운다(`Request.__call__` 기본값 120초를 무시) — 만료된
|
||||||
|
키·막힌 네트워크에서 무한정 걸리는 것을 막는다.
|
||||||
|
|
||||||
|
## 3. 오류 — `SearchConsoleError(code)`
|
||||||
|
|
||||||
|
`code` 문자열 하나만 들고 다닌다. **Google 응답 본문·액세스 토큰·키 파일 내용·원본 예외
|
||||||
|
메시지는 절대 담지 않는다** — 로그·잡 상태·관리 화면 어디로 흘러도 안전하다.
|
||||||
|
|
||||||
|
| code | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `invalid_credentials_file` | 키 파일을 못 읽거나 형식이 잘못됨 |
|
||||||
|
| `auth_failed` | 토큰 갱신 실패, 또는 갱신 후에도 토큰이 비어 있음 |
|
||||||
|
| `unauthorized` | HTTP 401 |
|
||||||
|
| `forbidden` | HTTP 403 |
|
||||||
|
| `rate_limited` | HTTP 429 |
|
||||||
|
| `server_error` | HTTP 5xx |
|
||||||
|
| `http_<code>` | 그 외 실패 상태코드 |
|
||||||
|
| `timeout` | 요청 타임아웃 |
|
||||||
|
| `transport_error` | 그 외 전송 실패(연결 끊김 등) |
|
||||||
|
| `invalid_json` | 200 인데 본문이 JSON 이 아님 |
|
||||||
|
| `missing_inspection_result` | 응답에 `inspectionResult` 가 없음 |
|
||||||
|
| `missing_index_status_result` | `inspectionResult` 는 있는데 `indexStatusResult` 가 없거나 빈 dict |
|
||||||
|
|
||||||
|
## 4. 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd solution/backend
|
||||||
|
APP_ENV=test .venv/bin/python -m pytest tests/test_search_console_client.py --confcutdir=tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`--confcutdir=tests` 가 필요한 이유: 저장소 루트 `conftest.py` 의 세션 스코프 autouse
|
||||||
|
픽스처가 실 Postgres 연결을 요구한다(`solution/backend/conftest.py`). 이 클라이언트
|
||||||
|
테스트는 DB 를 전혀 쓰지 않으므로 그 픽스처를 건너뛴다 — `--confcutdir=tests` 로 상위
|
||||||
|
`conftest.py` 탐색을 끊는다. (통합 후 전체 스위트를 돌릴 때는 이 플래그 없이 실행한다.)
|
||||||
|
|
||||||
|
Google 실 API 는 전부 `httpx.MockTransport` 로 막았다 — 네트워크 호출도, 과금도 없다.
|
||||||
@ -13,15 +13,30 @@ ssh King_admin # ~/.ssh/config 에 정의됨
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
|
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
|
||||||
| 계정 | `o2oadmin` |
|
| 계정 | `o2oadmin` |
|
||||||
| 경유 | `ProxyJump Confluence` = `59.14.81.3:14444` |
|
| 들어가는 문 | `59.14.81.3:14445` → 킹서버 22 **(2026-09-21 신설)** |
|
||||||
|
|
||||||
|
★ **14444 와 14445 는 서로 다른 서버로 가는 문이다.**
|
||||||
|
`14444` 는 **`.21` 서버**로 간다 — 예전에는 그리로 들어가 킹서버로 한 번 더 건너뛰었다
|
||||||
|
(`ProxyJump`). 인프라가 킹서버 전용 문 `14445` 를 열어 줘서 경유가 없어졌다.
|
||||||
|
→ `Confluence`(14444) 항목을 14445 로 **고치면 안 된다.** 그쪽은 `.21` 이 계속 쓴다.
|
||||||
|
|
||||||
★ `~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
|
★ `~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
|
||||||
|
|
||||||
```
|
```
|
||||||
Host King_admin
|
Host King_admin
|
||||||
|
HostName 59.14.81.3
|
||||||
|
Port 14445
|
||||||
|
User o2oadmin
|
||||||
|
IdentityFile ~/.ssh/<본인 키>
|
||||||
|
IdentitiesOnly yes
|
||||||
|
|
||||||
|
# 14445 가 막혔을 때의 옛 경로. 경유 서버를 거친다.
|
||||||
|
Host King_admin_jump
|
||||||
HostName 172.30.1.36
|
HostName 172.30.1.36
|
||||||
User o2oadmin
|
User o2oadmin
|
||||||
ProxyJump Confluence
|
ProxyJump Confluence
|
||||||
|
IdentityFile ~/.ssh/<본인 키>
|
||||||
|
IdentitiesOnly yes
|
||||||
|
|
||||||
Host Confluence
|
Host Confluence
|
||||||
HostName 59.14.81.3
|
HostName 59.14.81.3
|
||||||
@ -29,6 +44,18 @@ Host Confluence
|
|||||||
User o2oadmin
|
User o2oadmin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
★ **비밀번호로는 못 들어간다.** 키 등록분만 받는다(연구소 인원). 새 사람이 붙으려면 공개키를
|
||||||
|
등록해야 하고, 그건 이 레포 밖의 일이다.
|
||||||
|
|
||||||
|
★ 처음 붙으면 호스트 키 확인을 묻는다. `[59.14.81.3]:14445` 가 SSH 에게는 새 대상이기 때문이다 —
|
||||||
|
**서버가 바뀐 게 아니다.** 지문이 아래와 같으면 같은 서버다(실측 2026-09-21, SSH 가
|
||||||
|
`known_hosts` 의 `172.30.1.36` 항목과 같은 키라고 스스로 알려 준다).
|
||||||
|
|
||||||
|
```
|
||||||
|
ED25519 SHA256:oa/Nz42Liu0pFPJRnhjeVtfl+ov65aRwC6oVbgXe0/Y
|
||||||
|
ECDSA SHA256:ZzgVwQvycWW0Id0+4NHbHV/6RM7nu38aXU7jNhAJRgk
|
||||||
|
```
|
||||||
|
|
||||||
## 무엇이 올라가 있나
|
## 무엇이 올라가 있나
|
||||||
|
|
||||||
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
|
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
|
||||||
|
|||||||
141
docs/SOCIAL.md
Normal file
141
docs/SOCIAL.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# SNS 게재 — Threads
|
||||||
|
|
||||||
|
2026-09-14: 사용자 결정으로 X 구현을 제거하고 Threads를 첫 플랫폼으로 선택했다.
|
||||||
|
API 직접 연동에 공개된 건당 요금·유료 티어는 확인되지 않았다. 영구 무료를 보장한다는 뜻은 아니다.
|
||||||
|
[Meta 공식 API 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)은
|
||||||
|
앱 생성·사용자 인가·장기 토큰·텍스트 컨테이너/게시 API를 설명한다.
|
||||||
|
Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권한·최신 한도는 앱 콘솔에서 최종 확인한다.
|
||||||
|
|
||||||
|
## 사용 흐름
|
||||||
|
|
||||||
|
발행 모달의 **Threads에 알리기 → 소개글 쓰기**로 시작한다. 발행에 자동으로 붙지 않는다.
|
||||||
|
확인된 fact가 없거나, 사이트가 미발행이거나, 확정 domain/current_version_id가 없으면 생성하지 않는다.
|
||||||
|
본문은 완결된 짧은 문장과 서버가 계산한 발행 URL이다. 500자에는 링크도 포함한다.
|
||||||
|
문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다.
|
||||||
|
같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.
|
||||||
|
|
||||||
|
미니블로그 승인(이메일 링크 또는 빌더 앱 "바로 발행")도 계정이 연결돼 있으면 같은 문구를
|
||||||
|
그대로 쓰레드에 낸다(`decided_via='mini_blog'`) — 이 경로는 승인 요청·알림톡을 거치지 않고
|
||||||
|
바로 `APPROVED`로 들어간다. 미니블로그 승인 자체가 발화 동의로 취급되기 때문이다(2026-09-21,
|
||||||
|
DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정). 쓰레드 전용으로 새로 짓거나
|
||||||
|
내용을 바꾸는 경로(위 "발행 모달 → 소개글 쓰기")는 여전히 계정 연결 → 승인 요청 → 명시적
|
||||||
|
승인을 그대로 거친다.
|
||||||
|
|
||||||
|
- 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
|
||||||
|
- 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
|
||||||
|
- 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.
|
||||||
|
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
|
||||||
|
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
|
||||||
|
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
|
||||||
|
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
|
||||||
|
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
|
||||||
|
|
||||||
|
## 연동 준비 — 한 번만 하는 일
|
||||||
|
|
||||||
|
계정 연결은 **두 쪽이 나뉜다.** 우리가 한 번 준비하고(앱 등록), 사장님은 버튼 두 번을 누른다.
|
||||||
|
|
||||||
|
### 1. 우리가 한 번 (Meta 앱 콘솔)
|
||||||
|
|
||||||
|
1. 개발자 콘솔에서 앱을 만들고 **Threads API** 제품을 추가한다.
|
||||||
|
★ Threads 자격증명은 페이스북·인스타그램 앱의 것과 **별개**다. Threads 쪽 앱 ID·시크릿을 쓴다.
|
||||||
|
2. **리디렉션 콜백 URL** 에 `https://<발행호스트>/v1/social/oauth/callback` 을 등록한다.
|
||||||
|
★ **https 여야 한다.** `http://localhost` 는 콜백으로 등록되지 않는다 — 로컬에서 끝까지
|
||||||
|
돌려보려면 터널(cloudflared·ngrok)로 https 주소를 만들어 그 주소를 등록하거나,
|
||||||
|
https 가 붙어 있는 킹서버에서 확인한다. 이 제약 때문에 **연결만은 로컬 단독으로 검증되지 않는다.**
|
||||||
|
3. 권한은 `threads_basic` · `threads_content_publish` 둘이다(`services/external/threads.py SCOPES`).
|
||||||
|
4. **심사 전에는 앱 역할에 추가된 계정만 인가된다.** 시험할 사장님 Threads 계정을 테스터로
|
||||||
|
먼저 추가한다 — 이걸 빼먹으면 인가 화면까지 가서 거절당하고, 화면에는 `?social=failed` 만 뜬다.
|
||||||
|
5. 루트 `.env` 에 셋을 채우고 백엔드·워커를 다시 띄운다.
|
||||||
|
```
|
||||||
|
THREADS_APP_ID=…
|
||||||
|
THREADS_APP_SECRET=…
|
||||||
|
THREADS_REDIRECT_URI=https://<발행호스트>/v1/social/oauth/callback
|
||||||
|
```
|
||||||
|
★ `SOCIAL_TOKEN_SECRET`(Fernet 키)이 없으면 **연결 기능 자체가 꺼진다.** 평문으로 토큰을
|
||||||
|
보관하는 길은 만들지 않았다. 만드는 법: `python -c "from cryptography.fernet import Fernet;
|
||||||
|
print(Fernet.generate_key().decode())"`
|
||||||
|
★ 이 키를 잃어버리면 저장된 토큰을 복호화할 수 없다 — 모든 사장님이 **다시 연결**해야 한다
|
||||||
|
(그때 `TOKEN_KEY_CHANGED` 로 `needs_reauth` 가 된다).
|
||||||
|
|
||||||
|
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
|
||||||
|
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
|
||||||
|
|
||||||
|
### 2. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다
|
||||||
|
|
||||||
|
**연결(한 번)**: `/sites` **내 사이트** 화면 위의 `SNS 연동 · Threads` 카드 →
|
||||||
|
[Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 `@핸들` 이 뜬다.
|
||||||
|
|
||||||
|
**게재(사이트마다)**: 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.
|
||||||
|
|
||||||
|
★ **연결 버튼을 사업장 화면에 두지 않는다.** 계정은 `user × provider` 하나인데 버튼이
|
||||||
|
사업장 안에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 한 번, 게재는
|
||||||
|
사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
|
||||||
|
★ 앱 자격증명이 없으면 이 카드는 **아예 안 그려진다**(`GET /v1/social/account` 의
|
||||||
|
`connection_enabled`). 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이다.
|
||||||
|
|
||||||
|
### 3. 연결이 안 될 때 — 어디를 보나
|
||||||
|
|
||||||
|
콜백은 **화면에 이유를 내보내지 않는다**(OAuth 응답·state 에 자격증명이 들어 있다).
|
||||||
|
대신 서버 로그에 남는다:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose logs -f solution-backend | grep "\[social\]"
|
||||||
|
[social] 계정 연결 실패: SocialError: INVALID_OAUTH_STATE ← 쿠키 유실·10분 만료
|
||||||
|
[social] 계정 연결 실패: SocialError: THREADS_REJECTED_400 ← 앱 ID/시크릿·리디렉션 URI 불일치
|
||||||
|
[social] 계정 연결 중단(제공자 응답): access_denied ← 사장님이 인가를 취소함
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
|
||||||
|
저장하지 않아 **항상 `INVALID_OAUTH_STATE`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
|
||||||
|
|
||||||
|
## 보완한 안전장치
|
||||||
|
|
||||||
|
**POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
|
||||||
|
동일하게 API 성공 이후 DB 커밋 실패도 UNKNOWN으로 남긴다. 사람이 Threads에서 실제 결과를 확인해야 한다.
|
||||||
|
UNKNOWN에는 재게시 버튼이 없다. 플랫폼이 명확히 거절한 FAILED만 새 승인을 받을 수 있다.
|
||||||
|
게시 ID를 받았으면 permalink 조회 실패에도 POSTED로 기록하고 링크 없이 소식을 보여준다.
|
||||||
|
|
||||||
|
승인은 nonce 32바이트의 SHA-256과 PENDING_APPROVAL 조건부 UPDATE를 쓴다.
|
||||||
|
JWT_ACCESS_SECRET·로그인 토큰·사용자 role은 승인 URL에 들어가지 않는다.
|
||||||
|
승인 전이와 SOCIAL_POST 큐 삽입은 한 트랜잭션이다. GET은 만료 상태를 변경하지 않는다.
|
||||||
|
화면에서도 시각으로 만료를 표시하므로 스윕 지연이 승인 가능 표시로 이어지지 않는다.
|
||||||
|
재연결로 account_id가 바뀌면 옛 승인으로 게시할 수 없다. 게시 직전 소유자·사이트 상태·주소·계정을 재검사한다.
|
||||||
|
|
||||||
|
계정은 user에 붙는다. 연결 교체·해제·갱신·게시는 user/provider DB 잠금을 공유한다.
|
||||||
|
토큰은 Fernet 암호문만 저장하고 키가 없거나 형식이 잘못되면 연결하지 않는다.
|
||||||
|
OAuth state도 암호화하고 10분 TTL과 HttpOnly/Secure/SameSite 쿠키로 요청 브라우저에 묶는다.
|
||||||
|
Threads는 X의 offline.access/refresh_token을 쓰지 않는다. 장기 access token 자체를 갱신하며
|
||||||
|
만료·거절·저장 실패는 재연결 대상으로 처리한다. 자동 주기 갱신은 아직 없으므로 장기 미사용 뒤에는 다시 연결한다.
|
||||||
|
연결 해제는 보관 토큰을 제거하고 모든 업장의 이후 게시를 막는다. Threads에 이미 쓴 글은 지우지 않는다.
|
||||||
|
|
||||||
|
## 활성화 전 확인
|
||||||
|
|
||||||
|
기본 `SOCIAL_POSTING_ENABLED=0`. 지금 실행하지 않은 외부 작업은 다음과 같다.
|
||||||
|
|
||||||
|
1. Meta 앱 등록, 사용자 계정용 `threads_basic`·`threads_content_publish` 권한 심사와 테스트 계정 실게시.
|
||||||
|
2. HTTPS OAuth callback과 동일 오리진 쿠키 동작, 보안 키 보관·복원 절차 확인.
|
||||||
|
3. DECISIONS 1-4의 해지 안내 페이지 구현/검증. 현재 사이트 상태 전이만으로는 안내 HTML이 재생성되지 않는다.
|
||||||
|
이 선행조건을 해결하기 전에 운영 자동 게재를 활성화하지 않는다.
|
||||||
|
4. 알림톡 대행사 확정·발신프로필·템플릿 심사. 초안 리소스는 `services/resources/social_approval.json`.
|
||||||
|
승인 주소는 `#{승인주소}` 버튼 변수에 연결하고 문구는 심사본과 맞춘다. 아직 심사받은 템플릿이 아니다.
|
||||||
|
5. 발신번호·단가·야간 정책·24시간 만료를 운영 정책으로 확정.
|
||||||
|
|
||||||
|
`nginx/site.conf.example`의 `/approve/`·`/v1/social/` 블록을 실제 설정에도 반영한다.
|
||||||
|
앞단 프록시도 query string을 기록하지 않아야 한다. 앱 승인 페이지와 API는 no-store/no-referrer다.
|
||||||
|
마이그레이션 0012/0013 적용 후 빌더/API/워커/프리렌더를 배포한다.
|
||||||
|
발행 렌더러 변경은 전체 재굽기와 `republish_all.py`가 필요하며 payload 없는 목업은 대상이 아니다.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 메서드/경로 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| GET /v1/social/place/{place_id} | 소유자 범위 원고 목록·계정 표시 |
|
||||||
|
| POST /v1/social/place/{place_id}/draft | 초안/잡 원자 생성, 같은 버전 재사용 |
|
||||||
|
| POST /v1/social/posts/{post_id}/request-approval | nonce 발급·계정 고정·선택적 알림톡 |
|
||||||
|
| POST /v1/social/posts/{post_id}/decision | 로그인한 소유자의 화면 승인/거절 |
|
||||||
|
| GET /v1/social/approval/{post_id}?t=… | 무인증 읽기 전용 확인 |
|
||||||
|
| POST /v1/social/approval/{post_id}/decision | `{t, approve}` 일회성 결정 |
|
||||||
|
| GET /v1/social/account | 연결 상태만 — 사업장을 안 고르고 답한다(내 사이트 카드) |
|
||||||
|
| POST /v1/social/oauth/connect | Threads 인가 URL·브라우저 쿠키 발급 |
|
||||||
|
| GET /v1/social/oauth/callback | 코드 교환·암호문 보관 |
|
||||||
|
| POST /v1/social/oauth/disconnect | user 단위 모든 사업장 연결 해제 |
|
||||||
11
docs/TEAMS_WEBHOOK.md
Normal file
11
docs/TEAMS_WEBHOOK.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Teams 웹훅 확인 (2026-09-15)
|
||||||
|
|
||||||
|
- Adaptive Card 요청의 `contentUrl: null` 및 `$schema`를 공식 예제에 맞춰 보완했다.
|
||||||
|
- HTTP 202는 워크플로의 요청 접수다. Teams 채널 게시 성공을 뜻하지 않는다.
|
||||||
|
- 실제 전송 2건은 202였지만 사용자가 확인한 워크플로 실행은 실패였다.
|
||||||
|
상세 오류를 확인하지 못했으므로 누락 필드를 실제 실패 원인으로 단정하지 않는다.
|
||||||
|
- 운영 자동 알림 활성화 전, 채널 수신 또는 워크플로의 최종 게시 단계 성공을 확인해야 한다.
|
||||||
|
- 웹훅은 `.env`에만 보관하고 커밋하지 않는다.
|
||||||
|
|
||||||
|
검증: 백엔드에서 `APP_ENV=test PYTHONPATH=. .venv/bin/pytest tests/test_search_console_alerts.py --confcutdir=tests`.
|
||||||
|
공식 형식: https://learn.microsoft.com/en-us/connectors/teams/#adaptivecarditemschema
|
||||||
81
docs/WEATHER.md
Normal file
81
docs/WEATHER.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 오늘의 날씨
|
||||||
|
|
||||||
|
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
|
||||||
|
|
||||||
|
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
|
||||||
|
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. **API 키 없음** — Open-Meteo
|
||||||
|
는 키 발급 없이 쓰는 무료 공개 엔드포인트다(`services/external/open_meteo.py`).
|
||||||
|
|
||||||
|
## Open-Meteo 응답 → 내부 스냅샷
|
||||||
|
|
||||||
|
`GET https://api.open-meteo.com/v1/forecast?latitude=&longitude=¤t=temperature_2m,weather_code,wind_speed_10m&timezone=auto`
|
||||||
|
|
||||||
|
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
|
||||||
|
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로
|
||||||
|
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
|
||||||
|
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
|
||||||
|
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
|
||||||
|
|
||||||
|
- 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
|
||||||
|
- 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
|
||||||
|
|
||||||
|
둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
|
||||||
|
분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
|
||||||
|
|
||||||
|
| 코드 | WMO 의미(영어) | 분류(=조건 라벨) |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Clear sky | 맑음 |
|
||||||
|
| 1 | Mainly clear | 대체로 맑음 |
|
||||||
|
| 2 | Partly cloudy | 구름 조금 |
|
||||||
|
| 3 | Overcast | 흐림 |
|
||||||
|
| 45 | Fog | 안개 |
|
||||||
|
| 48 | Depositing rime fog | 착빙성 안개 |
|
||||||
|
| 51 | Drizzle: Light intensity | 가벼운 이슬비 |
|
||||||
|
| 53 | Drizzle: Moderate intensity | 보통 이슬비 |
|
||||||
|
| 55 | Drizzle: Dense intensity | 강한 이슬비 |
|
||||||
|
| 56 | Freezing Drizzle: Light intensity | 가벼운 착빙성 이슬비 |
|
||||||
|
| 57 | Freezing Drizzle: Dense intensity | 강한 착빙성 이슬비 |
|
||||||
|
| 61 | Rain: Slight intensity | 약한 비 |
|
||||||
|
| 63 | Rain: Moderate intensity | 보통 비 |
|
||||||
|
| 65 | Rain: Heavy intensity | 강한 비 |
|
||||||
|
| 66 | Freezing Rain: Light intensity | 약한 착빙성 비 |
|
||||||
|
| 67 | Freezing Rain: Heavy intensity | 강한 착빙성 비 |
|
||||||
|
| 71 | Snow fall: Slight intensity | 약한 눈 |
|
||||||
|
| 73 | Snow fall: Moderate intensity | 보통 눈 |
|
||||||
|
| 75 | Snow fall: Heavy intensity | 강한 눈 |
|
||||||
|
| 77 | Snow grains | 싸라기눈 |
|
||||||
|
| 80 | Rain showers: Slight | 약한 소나기 |
|
||||||
|
| 81 | Rain showers: Moderate | 보통 소나기 |
|
||||||
|
| 82 | Rain showers: Violent | 강한 소나기 |
|
||||||
|
| 85 | Snow showers: Slight | 약한 소나기눈 |
|
||||||
|
| 86 | Snow showers: Heavy | 강한 소나기눈 |
|
||||||
|
| 95 | Thunderstorm: Slight or moderate | 뇌우 |
|
||||||
|
| 96 | Thunderstorm with slight hail | 약한 우박 뇌우 |
|
||||||
|
| 99 | Thunderstorm with heavy hail | 강한 우박 뇌우 |
|
||||||
|
|
||||||
|
이 28개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만
|
||||||
|
안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기).
|
||||||
|
|
||||||
|
`weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
|
||||||
|
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
|
||||||
|
`/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
|
||||||
|
`눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection`의 `skyKey`는 노트에 그 조건 키가
|
||||||
|
실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
|
||||||
|
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
|
||||||
|
|
||||||
|
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
|
||||||
|
|
||||||
|
하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
|
||||||
|
고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
|
||||||
|
브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
|
||||||
|
중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
|
||||||
|
|
||||||
|
**세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
|
||||||
|
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
|
||||||
|
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
|
||||||
|
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
|
||||||
|
|
||||||
|
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
|
||||||
|
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
|
||||||
|
지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다.
|
||||||
|
옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다.
|
||||||
157
geo/README.md
157
geo/README.md
@ -1,157 +0,0 @@
|
|||||||
# geo/ — 검색·AI 엔진에 우리 사이트가 어떻게 보이는가
|
|
||||||
|
|
||||||
**최상단 프로젝트이면서, 백엔드 코드를 쓰는 모듈이다.** 이 두 가지가 같이 성립하는 이유가
|
|
||||||
이 문서의 대부분이다.
|
|
||||||
|
|
||||||
```
|
|
||||||
geo/
|
|
||||||
naver/
|
|
||||||
checks.py 판정 — 밖에서 HTTP 로 본다. DB 를 보지 않는다
|
|
||||||
robots.py robots.txt 를 **네이버 관점**으로 (Yeti·자산 차단·사이트맵 지시)
|
|
||||||
notify.py ★ 알리기 (IndexNow). 원래 백엔드 담당인데 고장 나서 여기가 맡는다
|
|
||||||
web_search.py 네이버 웹문서 검색 (여기 있는 이유는 '제약')
|
|
||||||
_http.py 공통 UA·타임아웃·실패 처리
|
|
||||||
scripts/
|
|
||||||
preflight.py ① 발행 전 — 통로가 뚫렸나 (오리진 단위)
|
|
||||||
postflight.py ② 발행 후 — 알리고 확인 (사이트 단위)
|
|
||||||
watch.py ③ 발행을 알아채서 ②를 자동 실행
|
|
||||||
check_naver_eo.py 전체 점검 (사람이 읽는 출력)
|
|
||||||
state.py state/ 무엇을 이미 알렸나 (git 에 안 올린다)
|
|
||||||
engines/ (아직 없음) GEO 측정 B1~B5 가 붙는 자리
|
|
||||||
```
|
|
||||||
|
|
||||||
## 언제 무엇을 도나
|
|
||||||
|
|
||||||
**발행 전과 후는 하는 일이 아예 다르다.** 섞으면 없는 주소를 통보하거나(404 통보 = 신뢰
|
|
||||||
손실), 매번 해야 할 일을 한 번만 하고 끝낸다.
|
|
||||||
|
|
||||||
| | 언제 | 무엇 | 단위 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `preflight.py` | 발행 **전** | 소유확인 · robots(Yeti·자산·사이트맵) · 루트 사이트맵 · **IndexNow 키 파일** | 오리진 |
|
|
||||||
| `postflight.py` | 발행 **후** | 살아 있나 확인 → 알린다 → 성공분만 기록 | 사이트 |
|
|
||||||
| `watch.py` | 계속 | 루트 사이트맵의 `lastmod` 변화를 보고 바뀐 것만 ② | 오리진 |
|
|
||||||
|
|
||||||
★★ **알리기는 발행 전에 하면 안 된다.** 아직 없는 주소를 통보하면 검색엔진이 404 를 받고,
|
|
||||||
그건 알리지 않은 것보다 나쁘다. `postflight` 가 **200 을 확인한 뒤에만** 보내는 이유다.
|
|
||||||
|
|
||||||
★ `watch.py` 는 **루트 사이트맵만 본다** — DB·잡 큐·볼륨을 들여다보지 않는다.
|
|
||||||
크롤러가 발행을 알아채는 방식과 같아서, `solution` 을 한 줄도 고치지 않고 끼어들 수 있다.
|
|
||||||
대가는 즉시성이다(기본 5분). 첫 실행은 전부 새 것으로 보이므로 `--seed` 로 한 번 재워 둔다.
|
|
||||||
|
|
||||||
★ **무엇을 목표로 잡는지는 [docs/NAVER_EO.md](../docs/NAVER_EO.md) 가 단일 출처다.**
|
|
||||||
결론만 옮기면: 네이버에서 우리 목표는 "AI 답변에 인용되기" 가 **아니고**,
|
|
||||||
**플레이스와 공식 홈페이지가 한 업소로 묶이고 웹문서 검색에 잡히는 것**이다 —
|
|
||||||
AI 브리핑의 출처가 네이버 생태계에 쏠려 있기 때문이다. 이 모듈의 점검 항목이 그 목표에서 나온다.
|
|
||||||
|
|
||||||
## 왜 최상단이면서 백엔드를 쓰나
|
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약 — [ARCHITECTURE.md 4절](../docs/ARCHITECTURE.md)).
|
|
||||||
`geo` 는 `solution`(사이트를 만든다)·`admin`(그걸 운영한다)과 **다루는 대상이 다르다** —
|
|
||||||
검색엔진과 AI 엔진이 밖에서 무엇을 보는지를 다룬다. 그래서 폴더를 가른다.
|
|
||||||
|
|
||||||
동시에 **도메인 코드를 복제하지 않는다.** `admin/backend` 가 이미 같은 처지이고 같은 방법을
|
|
||||||
쓴다 — `solution/backend` 를 PYTHONPATH 로 얹는다.
|
|
||||||
|
|
||||||
```
|
|
||||||
geo → solution/backend (services.external.naver · services.site_payload)
|
|
||||||
```
|
|
||||||
|
|
||||||
쓰는 것이 구체적으로 셋이다. 전부 **복제하면 조용히 틀리는** 값이다.
|
|
||||||
|
|
||||||
| 쓰는 것 | 복제하면 |
|
|
||||||
|---|---|
|
|
||||||
| `services.external.naver.NaverLocalClient` (지역검색) | 동일 업소 판정 근거와 쿼터 카운터가 갈린다 |
|
|
||||||
| `services.site_payload.publish_origin()` | 발행 호스트를 새 env 로 또 두면 canonical·사이트맵과 갈린다([AGENTS.md](../AGENTS.md) '발행 호스트는 두 곳') |
|
|
||||||
| `config.server_configs.external_api_config` | env 이름(`NAVER_CLIENT_ID` …)을 두 번 적으면 한쪽만 바뀌는 날이 온다 |
|
|
||||||
|
|
||||||
**읽어 쓰기만 한다 — `solution/backend` 의 파일은 고치지 않는다.** import 는 수정이 아니다.
|
|
||||||
|
|
||||||
★ **의존은 한 방향이다. `solution` 은 `geo` 를 import 하지 않는다.**
|
|
||||||
그래서 라우터를 붙일 때 **solution 의 라우터 트리에 끼우면 순환**이 된다 —
|
|
||||||
마운트는 진입점이 한다(`admin/backend/app.py`, 또는 `geo` 자신의 진입점).
|
|
||||||
이게 최상단으로 가른 대가이고, 동시에 경계가 지켜지는지 **import 한 줄로 드러나는** 이유다.
|
|
||||||
|
|
||||||
## 제약 — `solution/` 과 `admin/` 은 고치지 않는다 (2026-09-11)
|
|
||||||
|
|
||||||
두 폴더의 파일은 **한 줄도 고치지 않는다.** import 는 자유롭지만 수정은 안 된다.
|
|
||||||
그래서 세 가지가 이상적인 자리에 없다. 전부 **대가를 적어 두고** 간다.
|
|
||||||
|
|
||||||
| 원래 있어야 할 곳 | 지금 있는 곳 | 대가 |
|
|
||||||
|---|---|---|
|
|
||||||
| `NaverLocalClient.search_web()` | `geo/naver/web_search.py` | ⚠️ **쿼터는 하나인데 카운터가 둘이다.** 앱당 일 25,000회를 지역검색·웹문서검색이 공유하는데, 백엔드의 `call_counts()` 에는 여기 호출이 안 들어간다. 합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다 |
|
|
||||||
| `services/indexnow.py` 가 알리기 | `geo/naver/notify.py` | ⚠️ **담당이 두 곳이 되면 안 된다.** 지금은 백엔드가 0건이라 중복이 없지만, 백엔드를 고치면 같은 URL 이 두 번 나간다(429 대상). 그날 `GEO_NOTIFY_ENABLED=0` 으로 여기를 끈다 |
|
|
||||||
| `solution/backend/Dockerfile` 의 `COPY geo` | 없음 | ⚠️ **컨테이너에서 못 돈다.** 레포 체크아웃 + 백엔드 venv 로만 돈다(서버에서도). 정기 실행이 필요해지면 `geo/Dockerfile` 로 **자기 이미지**를 갖는 것이 이 제약 아래서의 길이다 |
|
|
||||||
| 소유확인을 랜딩 `<head>` 메타태그로 (`solution/frontend`) | `nginx/site.conf` 가 파일을 내준다 | ⚠️ **토큰이 두 곳에 산다** — `site.conf`(실제로 나가는 값)와 루트 `.env`(점검이 대조할 기대값). nginx 가 env 를 못 읽고 `site.conf` 는 `.example` 만 커밋되기 때문이다. **어긋나면 `checks.check_verification` 이 잡는다** — 그게 두 곳을 감수하는 근거다. 덤: 프론트 재빌드가 필요 없다 |
|
|
||||||
|
|
||||||
→ 제약이 풀리면 위 표의 왼쪽으로 옮기고 이 절을 지운다.
|
|
||||||
→ ★ 세 번째 줄은 **바꾸는 게 이득이 아닐 수도 있다.** 메타태그로 가면 토큰이 한 곳으로
|
|
||||||
모이지만 값을 바꿀 때마다 `./deploy.sh solution-site` 재빌드가 붙는다. 옮기기 전에
|
|
||||||
"토큰이 실제로 얼마나 바뀌나" 를 먼저 본다.
|
|
||||||
|
|
||||||
## 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 레포 루트에서. 백엔드 venv 를 쓴다(httpx·pydantic 이 거기 있다)
|
|
||||||
PY=solution/backend/.venv/bin/python
|
|
||||||
|
|
||||||
$PY geo/scripts/preflight.py # 발행 전 — 통로가 뚫렸나
|
|
||||||
$PY geo/scripts/postflight.py <slug> --dry-run # 무엇을 보낼지만 본다
|
|
||||||
$PY geo/scripts/postflight.py <slug> # 알린다
|
|
||||||
$PY geo/scripts/watch.py --seed # 첫 실행: 현재 상태를 재워 둔다
|
|
||||||
$PY geo/scripts/watch.py # 이후: 바뀐 것만 알린다
|
|
||||||
$PY geo/scripts/check_naver_eo.py --place "스테이,머뭄" # 전체 점검
|
|
||||||
```
|
|
||||||
|
|
||||||
읽는 환경변수는 **루트 `.env`** 다 — `SITE_PUBLIC_HOST` · `NAVER_SITE_VERIFICATION` ·
|
|
||||||
`NAVER_CLIENT_ID` · `NAVER_CLIENT_SECRET`. 자기 `.env` 를 두지 않는다.
|
|
||||||
|
|
||||||
## 지금 공개하는 것
|
|
||||||
|
|
||||||
```python
|
|
||||||
from geo import naver
|
|
||||||
|
|
||||||
report = await naver.probe(origin, place_name="스테이,머뭄", slug="butter")
|
|
||||||
report.ok # 실패가 없으면 True (WARN 은 실패로 세지 않는다)
|
|
||||||
report.findings # Finding(id, label, status, detail, fix)
|
|
||||||
report.as_dict() # 라우터가 그대로 내보낼 수 있는 형태
|
|
||||||
```
|
|
||||||
|
|
||||||
**라우터는 붙이지 않았다.** 어디서 쓸지는 화면을 만들 때 정한다 — 그래서 이 모듈은
|
|
||||||
`Finding` 목록만 돌려주고 화면에 찍지 않는다.
|
|
||||||
|
|
||||||
★ 붙일 때의 권고는 **:9801(어드민)** 이다. 소유확인·토큰·서치어드바이저는 우리 일이고,
|
|
||||||
`:9801` 은 앱 전체에 `role >= DEVELOPER` 가 걸리며 `127.0.0.1` 에만 열린다 — 사장님이 닿는
|
|
||||||
서버에 이 엔드포인트가 **아예 없다**. 사장님에게도 열 거라면 같은 router 를 `:9800` 에 다시
|
|
||||||
마운트하는 방식이고(admin 이 이미 그 방식이다), 그때 주의할 것: 상태가 "미색인" 일 때
|
|
||||||
**사장님이 할 수 있는 일이 없으면** 불안만 만들고 문의가 온다. 사장님에게 보일 것은
|
|
||||||
"스마트플레이스에 주소 넣기" 처럼 본인이 할 수 있는 것으로 추린다.
|
|
||||||
|
|
||||||
## 경계 — 지켜야 하는 선 (2026-09-11)
|
|
||||||
|
|
||||||
| | 규칙 | 왜 |
|
|
||||||
|---|---|---|
|
|
||||||
| `checks.py` | **DB 를 보지 않는다. 세션을 인자로도 받지 않는다** | "우리 DB 가 그렇다고 한다" 와 "밖에서 실제로 그렇게 보인다" 를 한 함수에 섞으면, 둘이 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 — **그 어긋남을 찾는 것이 이 모듈의 존재 이유**다 |
|
|
||||||
| 의존 | `geo → solution/backend` **한 방향** | 반대가 생기면 순환이다. 라우터는 진입점이 마운트한다(위) |
|
|
||||||
| 수집 | **공식 API 만.** 네이버 검색창을 긁지 않는다 | 봇 탐지 우회는 결론과 무관하게 영구 금지([DECISIONS 1-1](../docs/DECISIONS.md)) |
|
|
||||||
| 외부 호출 | 지역검색은 `services/external/naver.py` 를 통해서만. 웹문서검색만 예외 | 예외의 이유와 대가는 '제약' 절 |
|
|
||||||
| 심는 일 | **하지 않는다** | 소유확인 파일은 `nginx/site.conf` 가 내준다. 심는 쪽과 확인하는 쪽이 같으면 "내가 심었으니 있다" 를 확인이라고 부르게 된다 |
|
|
||||||
| `solution/`·`admin/` | **파일을 고치지 않는다.** import 만 한다 | 위 '제약' 절 |
|
|
||||||
|
|
||||||
## 아직 안 만든 것 — 붙이는 자리
|
|
||||||
|
|
||||||
기능을 얹을 때 **여기부터 읽고 위 표를 갱신한다.** 백엔드 코드를 *읽을* 수 있으므로
|
|
||||||
DB·모델까지 닿지만, **고칠 수 없다**는 제약이 붙는 자리가 있다 — 아래 주의 칸이 그것이다.
|
|
||||||
|
|
||||||
| | 어디에 | 주의 |
|
|
||||||
|---|---|---|
|
|
||||||
| 점검 결과 저장·추이 | 새 표 | ★ `init.sql` **과** `postgres-init/migrations/` **둘 다** 고친다. 그리고 **읽는 화면이 생긴 뒤에 만든다** — `ai_check_results` 가 아무도 안 읽는 표로 남았다가 마이그레이션 0006 에 떼였다 |
|
|
||||||
| 소유확인 주기 재확인 | `geo` 자기 스케줄러(또는 크론) | 검색엔진은 소유확인을 주기적으로 재확인한다. 태그가 사라지면 **알림 없이** 등록이 풀린다. ⚠️ 백엔드의 `JobType`·`worker/handlers.py` 에 얹으려면 **그 파일들을 고쳐야 한다** — 지금 제약에서 불가다. 그래서 이 제약이 풀리기 전까지는 `geo/Dockerfile` + compose 서비스가 유일한 길이다 |
|
|
||||||
| 통보 실패 재시도 | 잡 큐 | 통보는 발행의 부수 효과다 — 실패가 발행을 되돌리지 않는다는 `indexnow.py` 의 규칙을 깨지 않는다 |
|
|
||||||
| GEO 측정 (Brand AEO B1~B5) | `geo/engines/` | 먼저 정할 것은 **비용**이다. 설계서 기본값 100문항×4엔진×3회 = 테넌트당 주 1,200회로 사이트당 $1 상한과 부딪친다([DEVELOPMENT_DIRECTION 3-2](../docs/DEVELOPMENT_DIRECTION.md)) |
|
|
||||||
|
|
||||||
## 여기서 다시 보지 않는 것
|
|
||||||
|
|
||||||
`solution/backend/scripts/check_search_ready.py` 가 **이미** robots 의 Yeti 항목과 Yeti UA 의
|
|
||||||
발행본 접근을 본다. 같은 것을 두 군데서 보면 한쪽만 고쳐지는 날이 온다.
|
|
||||||
이 모듈은 그 스크립트가 **안 보는 것**만 본다 — 소유확인, 네이버 색인, 역방향 링크,
|
|
||||||
IndexNow 통보 URL, 그리고 **랜딩**(그 스크립트는 발행본만 본다).
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
"""GEO — 검색·AI 엔진에 우리 사이트가 어떻게 보이는지.
|
|
||||||
|
|
||||||
최상단 프로젝트이면서 **백엔드 코드를 쓰는 모듈**이다. `admin/backend` 와 같은 방식으로,
|
|
||||||
도메인 코드를 복제하지 않고 `solution/backend` 를 PYTHONPATH 로 얹어 쓴다
|
|
||||||
(`services.external.naver` 의 쿼터 카운터 · `services.site_payload.publish_origin`).
|
|
||||||
|
|
||||||
★ **의존은 `geo` → `solution/backend` 한 방향이다.** `solution` 이 `geo` 를 import 하면
|
|
||||||
순환이 된다 — 라우터를 붙일 때는 solution 의 라우터 트리가 아니라 **진입점**이 마운트한다
|
|
||||||
(`admin/backend/app.py`, 또는 geo 자신의 진입점). 근거는 README.md.
|
|
||||||
"""
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
"""네이버 탐색 최적화(Naver EO).
|
|
||||||
|
|
||||||
공개 면은 probe 하나다 — 라우터·워커 잡·CLI 가 **같은 함수**를 부른다.
|
|
||||||
경계와 아직 안 만든 것은 ../README.md.
|
|
||||||
"""
|
|
||||||
from geo.naver.checks import (
|
|
||||||
FAIL,
|
|
||||||
OK,
|
|
||||||
SKIP,
|
|
||||||
WARN,
|
|
||||||
Finding,
|
|
||||||
NaverEoReport,
|
|
||||||
probe,
|
|
||||||
verification_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ★ 쿼터 실사용은 이 값과 백엔드의 `services.external.naver.call_counts()` 를 **합쳐야**
|
|
||||||
# 나온다 — 네이버 검색 API 는 앱당 일 25,000회를 지역검색·웹문서검색이 공유하는데
|
|
||||||
# 호출기가 두 벌이라 카운터도 둘이다(web_search.py 머리주석).
|
|
||||||
from geo.naver.web_search import call_count as web_search_call_count
|
|
||||||
|
|
||||||
# 알리기. ★ 담당이 두 곳이 되면 안 된다 — 백엔드의 indexnow 가 고쳐지는 날
|
|
||||||
# `GEO_NOTIFY_ENABLED=0` 으로 여기를 끈다(notify.py 머리주석).
|
|
||||||
from geo.naver.notify import NotifyResult, notify_site
|
|
||||||
from geo.naver.notify import enabled as notify_enabled
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"probe",
|
|
||||||
"Finding",
|
|
||||||
"NaverEoReport",
|
|
||||||
"verification_token",
|
|
||||||
"web_search_call_count",
|
|
||||||
"notify_site",
|
|
||||||
"notify_enabled",
|
|
||||||
"NotifyResult",
|
|
||||||
"OK",
|
|
||||||
"WARN",
|
|
||||||
"FAIL",
|
|
||||||
"SKIP",
|
|
||||||
]
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
"""이 패키지가 밖으로 나갈 때 공통으로 쓰는 것.
|
|
||||||
|
|
||||||
★ 모듈 셋(checks·robots·notify)이 같은 UA·타임아웃·실패 처리를 쓴다. 각자 두면
|
|
||||||
"점검은 Yeti 로 받았는데 통보는 브라우저 UA" 같은 어긋남이 생기고, 그건 재현이 안 된다.
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# 네이버 검색 로봇의 실제 UA. 이걸로 받아 봐야 CDN·WAF 가 네이버만 막는 상태가 보인다.
|
|
||||||
YETI_UA = "Mozilla/5.0 (compatible; Yeti/1.1; +http://naver.me/spd)"
|
|
||||||
BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36"
|
|
||||||
|
|
||||||
TIMEOUT_SEC = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
async def get(client: httpx.AsyncClient, url: str, ua: str = BROWSER_UA) -> httpx.Response | None:
|
|
||||||
"""실패를 예외로 올리지 않는다 — 한 항목이 죽어도 나머지 점검은 끝까지 돈다."""
|
|
||||||
try:
|
|
||||||
return await client.get(url, headers={"User-Agent": ua})
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def host(origin: str) -> str:
|
|
||||||
return origin.split("//", 1)[-1].rstrip("/")
|
|
||||||
@ -1,330 +0,0 @@
|
|||||||
"""네이버가 우리 사이트를 어떻게 보는지 **밖에서** 확인한다.
|
|
||||||
|
|
||||||
★ 파일명이 `probe.py` 가 아닌 이유: 공개 함수가 `probe()` 라서 패키지에서 이름이 겹친다.
|
|
||||||
`from geo.naver import probe` 가 모듈이 아니라 함수를 주게 되고, `geo.naver.probe` 로
|
|
||||||
모듈 속성에 닿으려던 코드가 조용히 함수를 집는다(실제로 한 번 걸렸다).
|
|
||||||
|
|
||||||
★ 이 파일은 **DB 를 보지 않는다.** 세션을 인자로도 받지 않는다.
|
|
||||||
"우리 DB 가 그렇다고 한다" 와 "밖에서 실제로 그렇게 보인다" 를 한 함수에 섞으면,
|
|
||||||
둘이 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 — **그 어긋남을 찾는 것이 이 모듈의
|
|
||||||
존재 이유**다. 저장·판정 이력은 같은 패키지의 다른 파일이 맡는다(../README.md).
|
|
||||||
|
|
||||||
★ 화면에 찍지 않는다. `Finding` 목록만 돌려준다 — 라우터·워커 잡·CLI 가 같은 결과를
|
|
||||||
쓰게 하려는 것이다. 사람이 읽는 출력은 `geo/scripts/check_naver_eo.py` 가 만든다.
|
|
||||||
|
|
||||||
★ 공식 API 만 쓴다. 네이버 검색창을 긁지 않는다 — 봇 탐지 우회는 결론과 무관하게 영구
|
|
||||||
금지다(docs/DECISIONS.md 1-1).
|
|
||||||
|
|
||||||
★ 왜 네이버만 따로 보나 — 소상공인 검색 트래픽의 주력이면서, 우리가 가진 자동 경로가
|
|
||||||
IndexNow 하나뿐이다. 구글은 Search Console, Bing 은 웹마스터도구가 상태를 보여 주지만
|
|
||||||
네이버는 **서치어드바이저에 등록되기 전까지 아무 창도 없다.** 창이 없는 동안 잘못돼도
|
|
||||||
알 방법이 없어서, 지금 당장 확인할 수 있는 것만 본다.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from geo.naver._http import TIMEOUT_SEC, YETI_UA, get as _get, host as _host
|
|
||||||
from geo.naver.web_search import NaverWebSearchUnavailable, search_web
|
|
||||||
from geo.naver.web_search import enabled as web_search_enabled
|
|
||||||
from services.external.naver import NaverLocalClient, NaverNotConfigured, NaverRequestFailed
|
|
||||||
|
|
||||||
|
|
||||||
# 소유확인 토큰 = 서치어드바이저가 준 **파일명에서 `.html` 을 뺀 값**(예: naver1234abcd).
|
|
||||||
# ★ 이 값은 `nginx/site.conf` 에도 적힌다 — **두 곳이다.** nginx 가 env 를 못 읽고, 그 파일은
|
|
||||||
# `.example` 만 커밋되기 때문이다. 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것"
|
|
||||||
# 이고 **어긋나면 이 점검이 잡는다.** 그게 두 곳을 감수하는 근거다(geo/README.md '제약').
|
|
||||||
# ★ pydantic 설정이 아니라 env 를 직접 읽는다 — site_payload.SITE_HOST_ENV·indexnow.key() 와
|
|
||||||
# 같은 계열의 값이고, 발행 계열 env 는 그 관행이다.
|
|
||||||
VERIFICATION_ENV = "NAVER_SITE_VERIFICATION"
|
|
||||||
|
|
||||||
OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Finding:
|
|
||||||
"""점검 한 건. `fix` 는 **사람이 다음에 할 일**이다 — 없으면 할 일이 없다는 뜻이다."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
status: str
|
|
||||||
detail: str
|
|
||||||
fix: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NaverEoReport:
|
|
||||||
origin: str
|
|
||||||
findings: list[Finding] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def failed(self) -> list[Finding]:
|
|
||||||
return [f for f in self.findings if f.status == FAIL]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def warned(self) -> list[Finding]:
|
|
||||||
return [f for f in self.findings if f.status == WARN]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ok(self) -> bool:
|
|
||||||
"""실패가 없으면 True. **주의(WARN)는 실패로 세지 않는다** — 미확정이 섞여 있다
|
|
||||||
(네이버 색인은 등록 전에는 확정할 방법이 없다)."""
|
|
||||||
return not self.failed
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"origin": self.origin,
|
|
||||||
"ok": self.ok,
|
|
||||||
"findings": [asdict(f) for f in self.findings],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verification_token() -> str:
|
|
||||||
return os.environ.get(VERIFICATION_ENV, "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ── 소유확인 ─────────────────────────────────────────────────────────────
|
|
||||||
async def check_verification(client: httpx.AsyncClient, origin: str, token: str | None = None) -> Finding:
|
|
||||||
"""소유확인 파일이 오리진 루트에서 열리는가 — `https://<host>/<token>.html`.
|
|
||||||
|
|
||||||
★ **메타태그가 아니라 파일 방식이다.** 메타태그는 랜딩 `<head>` 에 들어가야 하는데 그건
|
|
||||||
`solution/frontend` 가 만든다 — `solution/` 을 고치지 않기로 했다(2026-09-11).
|
|
||||||
그래서 nginx 가 직접 내준다(`nginx/site.conf` 의 `location = /<token>.html`).
|
|
||||||
덤으로 얻은 것: **프론트를 다시 구울 필요가 없다.** 값이 번들에 안 들어간다.
|
|
||||||
|
|
||||||
★★ **상태코드로 판단하면 안 된다.** nginx 맨 아래 `location /` 가 SPA 폴백을 주므로,
|
|
||||||
블록이 없거나 파일명이 한 글자 틀리면 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다.
|
|
||||||
검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려주는데, 눈으로는 파일이 있는 것처럼 보인다.
|
|
||||||
→ 그래서 **내용**으로 본다. `docs/DEPLOY.md` 가 Bing 파일에 대해 하는 경고와 같은 것이다.
|
|
||||||
|
|
||||||
★ 통과했다고 끝이 아니다. 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이 사라진
|
|
||||||
시점에 등록이 풀리면서 **아무 알림도 오지 않는다.**
|
|
||||||
"""
|
|
||||||
token = verification_token() if token is None else token.strip()
|
|
||||||
if not token:
|
|
||||||
return Finding(
|
|
||||||
"verification", "소유확인 파일", WARN,
|
|
||||||
f"{VERIFICATION_ENV} 가 비었다 — 서치어드바이저 등록 전이다",
|
|
||||||
"docs/DEPLOY.md 2-2단계",
|
|
||||||
)
|
|
||||||
|
|
||||||
path = f"/{token}.html"
|
|
||||||
res = await _get(client, origin + path)
|
|
||||||
if res is None:
|
|
||||||
return Finding("verification", "소유확인 파일", FAIL, f"{path} 에 연결하지 못했다")
|
|
||||||
if res.status_code != 200:
|
|
||||||
return Finding(
|
|
||||||
"verification", "소유확인 파일", FAIL,
|
|
||||||
f"{path} 이 HTTP {res.status_code}",
|
|
||||||
"nginx/site.conf 에 location = " + path + " 블록이 있는지 확인해라",
|
|
||||||
)
|
|
||||||
if token not in res.text:
|
|
||||||
# 여기가 이 점검의 핵심이다 — 200 인데 내용이 다르면 거의 항상 SPA 폴백이다.
|
|
||||||
shell = "id=\"root\"" in res.text or len(res.text) > 2000
|
|
||||||
why = "빌더 앱 HTML 이 200 으로 나온다" if shell else "내용에 토큰이 없다"
|
|
||||||
return Finding(
|
|
||||||
"verification", "소유확인 파일", FAIL,
|
|
||||||
f"{path} 이 200 이지만 {why} ({len(res.text)}바이트)",
|
|
||||||
"location 블록이 없거나 파일명이 다르다 — nginx/site.conf 를 확인하고 reload 해라",
|
|
||||||
)
|
|
||||||
return Finding("verification", "소유확인 파일", OK, f"{path} — 토큰이 내용에 있다 ({len(res.text)}바이트)")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_yeti_landing(client: httpx.AsyncClient, origin: str) -> Finding:
|
|
||||||
"""랜딩을 **Yeti UA 로** 받아 본다.
|
|
||||||
|
|
||||||
★ `scripts/check_search_ready.py` 는 발행본(`/s/<slug>`)만 본다. 랜딩은 아무도 안 보는데,
|
|
||||||
소유확인이 여기 붙고 네이버가 사이트를 처음 여는 자리도 여기다.
|
|
||||||
|
|
||||||
★ 글자 수를 세는 이유: 랜딩은 프리렌더 대상이다(`react-router.config.ts` `prerender`).
|
|
||||||
그 설정이 빠지면 200 은 그대로인데 본문이 0자가 된다 — 실측(2026-09-07) 3,021바이트에
|
|
||||||
`<a>` 0개·본문 0자였다. 상태코드로는 안 보이는 종류다.
|
|
||||||
"""
|
|
||||||
res = await _get(client, origin + "/", ua=YETI_UA)
|
|
||||||
if res is None:
|
|
||||||
return Finding("yeti_landing", "Yeti 로 랜딩", FAIL, "연결하지 못했다")
|
|
||||||
if res.status_code != 200:
|
|
||||||
return Finding(
|
|
||||||
"yeti_landing", "Yeti 로 랜딩", FAIL,
|
|
||||||
f"HTTP {res.status_code} — CDN·WAF 가 네이버 로봇을 막고 있다",
|
|
||||||
"봇 차단 규칙에서 Yeti 를 빼라",
|
|
||||||
)
|
|
||||||
words = _visible_chars(res.text)
|
|
||||||
if words < 300:
|
|
||||||
return Finding(
|
|
||||||
"yeti_landing", "Yeti 로 랜딩", FAIL,
|
|
||||||
f"본문 {words}자 — CSR 로 돌아갔다",
|
|
||||||
"react-router.config.ts 의 prerender 목록을 확인해라",
|
|
||||||
)
|
|
||||||
return Finding("yeti_landing", "Yeti 로 랜딩", OK, f"HTTP 200 · 본문 {words}자")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 색인 통보 경로 ───────────────────────────────────────────────────────
|
|
||||||
async def check_indexnow_path(client: httpx.AsyncClient, origin: str, slug: str | None = None) -> list[Finding]:
|
|
||||||
"""백엔드가 통보 URL 을 뽑는 파일이 **그 자리에 있는가.**
|
|
||||||
|
|
||||||
★ `services/indexnow.py site_urls()` 는 `<out>/s/<slug>/sitemap.xml` 을 읽어 `<loc>` 을
|
|
||||||
모은다. 없으면 빈 목록을 돌려주고 경고 한 줄만 남긴 뒤 **발행 잡은 성공한다.**
|
|
||||||
네이버로 가는 자동 경로가 이것뿐이라, 끊기면 통째로 끊긴다.
|
|
||||||
|
|
||||||
★ 로컬 `out/` 대신 HTTP 로 본다. 프리렌더가 쓰는 볼륨과 nginx 가 읽는 볼륨이 같아서,
|
|
||||||
밖에서 404 면 백엔드가 읽을 파일도 없다. 그리고 로컬 경로를 아는 것은 백엔드 몫이라
|
|
||||||
여기서 다시 조립하면 규칙이 두 군데가 된다.
|
|
||||||
"""
|
|
||||||
out: list[Finding] = []
|
|
||||||
res = await _get(client, origin + "/sitemap.xml")
|
|
||||||
if res is None or res.status_code != 200:
|
|
||||||
code = res.status_code if res else "연결실패"
|
|
||||||
out.append(Finding(
|
|
||||||
"root_sitemap", "루트 사이트맵", FAIL,
|
|
||||||
f"HTTP {code} — 통보할 URL 목록의 출처가 없다",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|
||||||
locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text)
|
|
||||||
site_locs = [loc for loc in locs if "/s/" in loc]
|
|
||||||
out.append(Finding(
|
|
||||||
"root_sitemap", "루트 사이트맵", OK,
|
|
||||||
f"URL {len(locs)}개 (발행 사이트 {len(site_locs)}개)",
|
|
||||||
))
|
|
||||||
|
|
||||||
if not slug:
|
|
||||||
found = re.search(r"/s/([^/?#]+)", site_locs[0]) if site_locs else None
|
|
||||||
slug = found.group(1) if found else None
|
|
||||||
if not slug:
|
|
||||||
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", SKIP, "확인할 사이트가 없다"))
|
|
||||||
return out
|
|
||||||
|
|
||||||
res = await _get(client, f"{origin}/s/{slug}/sitemap.xml")
|
|
||||||
if res is None:
|
|
||||||
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", FAIL, "연결하지 못했다"))
|
|
||||||
elif res.status_code == 200 and "<loc>" in res.text:
|
|
||||||
n = len(re.findall(r"<loc>", res.text))
|
|
||||||
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", OK, f"/s/{slug}/sitemap.xml — URL {n}개"))
|
|
||||||
else:
|
|
||||||
out.append(Finding(
|
|
||||||
"indexnow_urls", "IndexNow 통보 URL", FAIL,
|
|
||||||
f"/s/{slug}/sitemap.xml 이 HTTP {res.status_code} — indexnow.site_urls() 가 빈 목록을 "
|
|
||||||
"돌려준다. 네이버·Bing 통보가 조용히 0건이다",
|
|
||||||
"indexnow.site_urls() 가 루트 사이트맵을 읽게 고쳐라",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ── 네이버에서 보이는가 ──────────────────────────────────────────────────
|
|
||||||
async def check_web_index(origin: str, place_name: str) -> Finding:
|
|
||||||
"""웹문서 검색에 우리 URL 이 잡히는가.
|
|
||||||
|
|
||||||
★ 없을 때 FAIL 이 아니라 WARN 이다 — 이 API 의 결과는 통합검색 색인과 같지 않아서
|
|
||||||
"안 잡혔다" 로 "색인 안 됐다" 를 단정할 수 없다(web_search.py 머리주석).
|
|
||||||
확정 판정을 보려면 서치어드바이저 등록이 필요하고, 그게 소유확인이 전제인 이유다.
|
|
||||||
|
|
||||||
★ 지역검색(`check_place_backlink`)은 백엔드 호출기를 그대로 쓰는데 여기만 geo 안의
|
|
||||||
호출기를 쓴다 — 백엔드를 고치지 않기로 했기 때문이다. 쿼터가 갈리는 대가는
|
|
||||||
`web_search.py` 머리주석에 적어 뒀다.
|
|
||||||
"""
|
|
||||||
if not web_search_enabled():
|
|
||||||
return Finding("web_index", "네이버 웹문서 색인", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
|
|
||||||
try:
|
|
||||||
items = await search_web(place_name)
|
|
||||||
except NaverWebSearchUnavailable as ex:
|
|
||||||
return Finding("web_index", "네이버 웹문서 색인", FAIL, str(ex))
|
|
||||||
|
|
||||||
host = _host(origin)
|
|
||||||
hits = [i for i in items if host in (i.get("link") or "")]
|
|
||||||
if hits:
|
|
||||||
return Finding("web_index", "네이버 웹문서 색인", OK, f'"{place_name}" 검색 {len(items)}건 중 우리 사이트 {len(hits)}건')
|
|
||||||
return Finding(
|
|
||||||
"web_index", "네이버 웹문서 색인", WARN,
|
|
||||||
f'"{place_name}" 검색 {len(items)}건에 우리 사이트가 없다 — 미색인이거나 이 API 범위 밖이다',
|
|
||||||
"서치어드바이저에 등록해 색인 진단을 봐라",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_place_backlink(origin: str, place_name: str, client: NaverLocalClient | None = None) -> Finding:
|
|
||||||
"""네이버 플레이스가 **우리 사이트를 가게 홈페이지로 가리키는가.**
|
|
||||||
|
|
||||||
★ 왜 중요한가 — 우리는 `sameAs` 로 네이버를 가리키지만 그건 우리→네이버 한 방향이다.
|
|
||||||
네이버가 이 홈페이지를 그 업소의 공식 사이트로 **인정하는** 신호는 반대 방향,
|
|
||||||
스마트플레이스의 "홈페이지" 칸에 우리 주소가 들어가는 것이다. 사장님이 직접 넣어야
|
|
||||||
하고, 비용이 0 이면서 가장 강한 신호다.
|
|
||||||
|
|
||||||
★ 보는 필드는 `NaverPlace.place_url` 이다. 이 값은 응답의 `link` 인데 **네이버 플레이스
|
|
||||||
페이지가 아니라 업체 자체 홈페이지**다(external/naver.py 실측 주석) — 그래서 역방향
|
|
||||||
연결을 여기서 볼 수 있다. 이름이 `link` 가 아니라는 것에 주의한다.
|
|
||||||
|
|
||||||
★ 후보는 최대 5건이고 전화번호가 안 와서 동명 업소를 가릴 근거가 약하다. 그래서
|
|
||||||
"일치/불일치" 로 단정하지 않고 **후보의 link 를 그대로 담는다** — 판단은 사람이 한다.
|
|
||||||
"""
|
|
||||||
api = client or NaverLocalClient()
|
|
||||||
if not api.enabled:
|
|
||||||
return Finding("place_backlink", "스마트플레이스 역방향 링크", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
|
|
||||||
try:
|
|
||||||
candidates = await api.search_local(place_name)
|
|
||||||
except (NaverNotConfigured, NaverRequestFailed) as ex:
|
|
||||||
return Finding("place_backlink", "스마트플레이스 역방향 링크", FAIL, f"{type(ex).__name__}: {ex}")
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return Finding(
|
|
||||||
"place_backlink", "스마트플레이스 역방향 링크", WARN,
|
|
||||||
f'"{place_name}" 로 네이버 지역검색 결과가 없다',
|
|
||||||
)
|
|
||||||
|
|
||||||
host = _host(origin)
|
|
||||||
for cand in candidates:
|
|
||||||
# name 은 from_item 에서 이미 <b> 태그를 벗긴 값이다 — 다시 벗기지 않는다.
|
|
||||||
if host in (cand.place_url or ""):
|
|
||||||
return Finding(
|
|
||||||
"place_backlink", "스마트플레이스 역방향 링크", OK,
|
|
||||||
f"{cand.name} → {cand.place_url}",
|
|
||||||
)
|
|
||||||
seen = " · ".join(f"{c.name}={c.place_url or '(없음)'}" for c in candidates)
|
|
||||||
return Finding(
|
|
||||||
"place_backlink", "스마트플레이스 역방향 링크", WARN,
|
|
||||||
f"우리 주소가 없다 — 후보: {seen}",
|
|
||||||
"스마트플레이스 '홈페이지' 칸에 발행본 주소를 넣도록 사장님께 안내해라",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 한 번에 ──────────────────────────────────────────────────────────────
|
|
||||||
async def probe(
|
|
||||||
origin: str,
|
|
||||||
*,
|
|
||||||
place_name: str | None = None,
|
|
||||||
slug: str | None = None,
|
|
||||||
token: str | None = None,
|
|
||||||
naver: NaverLocalClient | None = None,
|
|
||||||
) -> NaverEoReport:
|
|
||||||
"""전체 점검. 라우터·워커 잡·CLI 가 **같이 부르는 자리**다.
|
|
||||||
|
|
||||||
`place_name` 이 없으면 네이버 검색이 필요한 두 항목을 건너뛴다 — 상호명 없이는
|
|
||||||
질의를 만들 수 없고, 호스트명으로 던지면 결과가 의미를 갖지 않는다."""
|
|
||||||
report = NaverEoReport(origin=origin.rstrip("/"))
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
|
|
||||||
report.findings.append(await check_verification(client, report.origin, token))
|
|
||||||
report.findings.append(await check_yeti_landing(client, report.origin))
|
|
||||||
report.findings.extend(await check_indexnow_path(client, report.origin, slug))
|
|
||||||
|
|
||||||
if place_name:
|
|
||||||
# 웹문서검색은 geo 안의 호출기, 지역검색은 백엔드 호출기다(check_web_index 주석).
|
|
||||||
report.findings.append(await check_web_index(report.origin, place_name))
|
|
||||||
api = naver or NaverLocalClient()
|
|
||||||
try:
|
|
||||||
report.findings.append(await check_place_backlink(report.origin, place_name, api))
|
|
||||||
finally:
|
|
||||||
if naver is None:
|
|
||||||
await api.aclose()
|
|
||||||
else:
|
|
||||||
report.findings.append(
|
|
||||||
Finding("naver_search", "색인·역방향 링크", SKIP, "상호명을 주면 확인한다")
|
|
||||||
)
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
# ── 내부 ─────────────────────────────────────────────────────────────────
|
|
||||||
def _visible_chars(html: str) -> int:
|
|
||||||
"""JS 실행 없이 읽히는 글자 수. check_search_ready.py 와 같은 셈이다."""
|
|
||||||
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S)
|
|
||||||
return len(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body)).strip())
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
"""발행본 주소를 검색엔진에 **알린다** — 크롤러가 지나가길 기다리지 않는다.
|
|
||||||
|
|
||||||
★ 어디에 닿고 어디에 안 닿나 — 이게 이 파일의 존재 이유다.
|
|
||||||
닿는다 네이버(2023-07~) · Bing · Yandex · Seznam.
|
|
||||||
★ 네이버는 **여기 말고 자동 통로가 없다.** 서치어드바이저는 사람이 눌러야 한다.
|
|
||||||
안 닿는다 구글. IndexNow 를 채택하지 않았다 — 사이트맵 제출이 유일한 자동화다.
|
|
||||||
|
|
||||||
★★ **왜 `geo` 가 이 일을 하나.**
|
|
||||||
원래 담당은 `solution/backend/services/indexnow.py` 다. 그런데 그 코드가 읽는 파일
|
|
||||||
(`<out>/s/<slug>/sitemap.xml`)을 프리렌더가 **더는 굽지 않는다** — 사이트가 한 장이 되면서
|
|
||||||
루트 사이트맵 한 장으로 합쳤기 때문이다. 파일이 없으면 빈 목록을 돌려주고 경고 한 줄만
|
|
||||||
남긴 채 **발행 잡은 성공한다.** 즉 통보가 조용히 0건이다.
|
|
||||||
백엔드를 고치지 않기로 해서(2026-09-11), `geo` 가 **루트 사이트맵을 읽어** 대신 보낸다.
|
|
||||||
|
|
||||||
⚠️ **담당이 두 곳이 되면 안 된다.** 지금은 백엔드가 0건이라 중복이 없지만, 누가 백엔드를
|
|
||||||
고치면 **같은 URL 이 두 번 나간다**(429 과다 요청 대상). 그때는 둘 중 하나를 꺼야 한다 —
|
|
||||||
이 모듈은 `GEO_NOTIFY_ENABLED=0` 으로 끈다.
|
|
||||||
|
|
||||||
★ 보낼 URL 을 여기서 조립하지 않는다. **사이트맵에 있는 것만 보낸다** — 거기 있는 것이
|
|
||||||
실제로 구워진 페이지다. 라우트 규칙을 또 두면 사이트맵에 없는 URL 을 통보하게 되고,
|
|
||||||
그건 404 통보라 신뢰만 깎인다.
|
|
||||||
|
|
||||||
★ 실패해도 발행을 되돌리지 않는다. 정적 파일은 이미 올라가 있어서 되돌릴 것이 없다.
|
|
||||||
대신 **조용히 지나가지 않게** 결과를 돌려준다 — 그게 지금 고장의 재발 방지다.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from geo.naver._http import TIMEOUT_SEC, get
|
|
||||||
|
|
||||||
ENDPOINT = "https://api.indexnow.org/indexnow"
|
|
||||||
# 규격 상한은 한 번에 10,000개다. 사이트 하나는 한 장이라 넉넉하다.
|
|
||||||
MAX_URLS = 10_000
|
|
||||||
|
|
||||||
KEY_ENV = "INDEXNOW_KEY"
|
|
||||||
ENABLED_ENV = "GEO_NOTIFY_ENABLED"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NotifyResult:
|
|
||||||
slug: str
|
|
||||||
urls: list[str]
|
|
||||||
sent: bool
|
|
||||||
status: int | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ok(self) -> bool:
|
|
||||||
# 200 OK · 202 Accepted 가 정상이다. 그 밖은 규격상 원인이 정해져 있다:
|
|
||||||
# 400 형식 · 403 키 불일치 · 422 호스트 불일치 · 429 과다 요청
|
|
||||||
return self.sent and self.status in (200, 202)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {**asdict(self), "ok": self.ok}
|
|
||||||
|
|
||||||
|
|
||||||
def key() -> str:
|
|
||||||
return os.environ.get(KEY_ENV, "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def enabled() -> bool:
|
|
||||||
"""키가 있고, 꺼져 있지 않을 때만 보낸다.
|
|
||||||
|
|
||||||
★ 스위치를 둔 이유는 머리주석의 "담당이 두 곳" 경고 때문이다. 백엔드 쪽이 고쳐지는
|
|
||||||
날 여기를 꺼야 하는데, 코드를 지우는 것보다 환경변수 하나가 되돌리기 쉽다."""
|
|
||||||
return bool(key()) and os.environ.get(ENABLED_ENV, "1").strip() != "0"
|
|
||||||
|
|
||||||
|
|
||||||
async def root_sitemap_locs(client: httpx.AsyncClient, origin: str) -> list[str]:
|
|
||||||
"""루트 사이트맵의 `<loc>` 전부. **이 호스트가 실제로 발행한 주소 목록**이다.
|
|
||||||
|
|
||||||
★ XML 파서를 쓰지 않고 정규식으로 뽑는다. 우리가 굽는 파일이라 형태가 고정이고,
|
|
||||||
파서를 쓰면 한 글자 깨졌을 때 전부를 잃는다 — 통보는 부분 성공이 낫다."""
|
|
||||||
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
|
|
||||||
if res is None or res.status_code != 200:
|
|
||||||
return []
|
|
||||||
return [u.strip() for u in re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text) if u.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def site_urls(locs: list[str], slug: str) -> list[str]:
|
|
||||||
"""이 사이트에 속한 주소만 고른다.
|
|
||||||
|
|
||||||
★ `/s/<slug>` 로 시작하는 것만 본다. `startswith` 가 아니라 경계까지 보는 이유:
|
|
||||||
`/s/joy` 로 거르면 `/s/joy-cafe` 까지 끌려온다."""
|
|
||||||
marker = f"/s/{slug}"
|
|
||||||
picked = []
|
|
||||||
for loc in locs:
|
|
||||||
idx = loc.find(marker)
|
|
||||||
if idx < 0:
|
|
||||||
continue
|
|
||||||
rest = loc[idx + len(marker):]
|
|
||||||
if rest in ("", "/") or rest.startswith(("/", "?", "#")):
|
|
||||||
picked.append(loc)
|
|
||||||
return picked[:MAX_URLS]
|
|
||||||
|
|
||||||
|
|
||||||
async def submit(client: httpx.AsyncClient, urls: list[str]) -> NotifyResult | None:
|
|
||||||
"""한 호스트분을 보낸다. 호출측이 slug 를 채워 돌려받는다."""
|
|
||||||
host = urls[0].split("//", 1)[-1].split("/", 1)[0]
|
|
||||||
body = {
|
|
||||||
"host": host,
|
|
||||||
"key": key(),
|
|
||||||
# 키 파일은 오리진 루트에 있다(프리렌더가 굽는다). 검색엔진이 이걸 열어
|
|
||||||
# 같은 키가 있는지 보고 "이 호스트를 제어하는 쪽이 보냈다" 를 확인한다.
|
|
||||||
# 비밀이 아니다 — 공개되어야 작동하는 값이다.
|
|
||||||
"keyLocation": f"https://{host}/{key()}.txt",
|
|
||||||
# 한 요청의 URL 은 전부 같은 호스트여야 한다(규격). 섞이면 422 다.
|
|
||||||
"urlList": [u for u in urls if u.split("//", 1)[-1].split("/", 1)[0] == host],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
res = await client.post(ENDPOINT, json=body, timeout=TIMEOUT_SEC)
|
|
||||||
except httpx.HTTPError as ex:
|
|
||||||
return NotifyResult("", body["urlList"], sent=True, error=f"{type(ex).__name__}: {ex}")
|
|
||||||
return NotifyResult("", body["urlList"], sent=True, status=res.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_site(client: httpx.AsyncClient, origin: str, slug: str, locs: list[str] | None = None) -> NotifyResult:
|
|
||||||
"""사이트 하나를 알린다. `locs` 를 주면 사이트맵을 다시 읽지 않는다(여러 건 처리용)."""
|
|
||||||
if not enabled():
|
|
||||||
return NotifyResult(slug, [], sent=False, error=f"{KEY_ENV} 가 없거나 {ENABLED_ENV}=0")
|
|
||||||
|
|
||||||
if locs is None:
|
|
||||||
locs = await root_sitemap_locs(client, origin)
|
|
||||||
urls = site_urls(locs, slug)
|
|
||||||
if not urls:
|
|
||||||
return NotifyResult(slug, [], sent=False, error="루트 사이트맵에 이 사이트 주소가 없다")
|
|
||||||
|
|
||||||
result = await submit(client, urls)
|
|
||||||
result.slug = slug
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def check_key_file(client: httpx.AsyncClient, origin: str) -> tuple[bool, str]:
|
|
||||||
"""키 파일이 열리는가 — **이게 없으면 통보가 403 으로 전부 거절된다.**
|
|
||||||
|
|
||||||
발행 전에 봐야 하는 항목이다. 통보를 보내고 나서 알면 이미 늦다."""
|
|
||||||
k = key()
|
|
||||||
if not k:
|
|
||||||
return False, f"{KEY_ENV} 가 비어 있다 — 통보가 꺼져 있다"
|
|
||||||
res = await get(client, f"{origin.rstrip('/')}/{k}.txt")
|
|
||||||
if res is None:
|
|
||||||
return False, f"/{k}.txt 에 연결하지 못했다"
|
|
||||||
if res.status_code != 200:
|
|
||||||
return False, f"/{k}.txt 이 HTTP {res.status_code} — 통보가 403 으로 거절된다"
|
|
||||||
if res.text.strip() != k:
|
|
||||||
return False, f"/{k}.txt 내용이 키와 다르다"
|
|
||||||
return True, f"/{k}.txt"
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
"""`robots.txt` 를 **네이버 관점으로** 읽는다.
|
|
||||||
|
|
||||||
★ 이 파일이 보는 것과 `solution/backend/scripts/check_search_ready.py` 가 보는 것은 다르다.
|
|
||||||
겹치면 한쪽만 고쳐지는 날이 오므로 경계를 적어 둔다.
|
|
||||||
|
|
||||||
check_search_ready : 이 호스트가 **AI 크롤러 전반**(GPTBot·ClaudeBot·PerplexityBot…)에
|
|
||||||
열려 있나 — 구글·AI 검색 관점
|
|
||||||
여기 : **네이버가 수집할 수 있나** — Yeti·Daumoa 허용, 사이트맵 지시,
|
|
||||||
그리고 ★ **JS·CSS 리소스를 막지 않았나**
|
|
||||||
|
|
||||||
★★ 마지막 항목이 네이버 고유다. 네이버 가이드는 robots.txt 로 JS·CSS 리소스를 막으면
|
|
||||||
**그 페이지 자체가 수집되지 않는다**고 명시한다. 우리 발행본은 정적 HTML 이라 JS 없이도
|
|
||||||
읽히지만, 자산이 막히면 네이버는 페이지를 "덜 읽은" 것이 아니라 **아예 안 가져간다.**
|
|
||||||
robots 에 `/assets` 류를 막는 줄이 끼어드는 순간 조용히 전부 빠진다.
|
|
||||||
|
|
||||||
★ robots.txt 는 **오리진 루트에서만** 읽힌다(RFC 9309). `/s/<slug>/robots.txt` 는 아무도
|
|
||||||
안 본다 — 그래서 여기서도 루트만 본다.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from geo.naver._http import get
|
|
||||||
|
|
||||||
# 네이버·다음 검색 로봇. 둘 다 이름으로 명시돼 있어야 안전하다 —
|
|
||||||
# 일부 로봇은 와일드카드보다 자기 이름 규칙을 우선으로 본다.
|
|
||||||
NAVER_BOTS = ("Yeti", "Daumoa")
|
|
||||||
|
|
||||||
# 막히면 네이버가 페이지를 통째로 안 가져가는 자산 경로.
|
|
||||||
ASSET_PREFIXES = ("/assets", "/fonts", "/static", "/_next", "/builder-assets")
|
|
||||||
|
|
||||||
|
|
||||||
def _blocks(body: str) -> dict[str, list[str]]:
|
|
||||||
"""`User-agent` 별 Disallow 목록. 한 그룹에 UA 가 여럿일 수 있다(규격).
|
|
||||||
|
|
||||||
★ 그룹은 빈 줄로만 끊기는 게 아니다. **규칙(Disallow…) 뒤에 오는 `User-agent` 는
|
|
||||||
새 그룹의 시작**이다(RFC 9309). 빈 줄만 보고 끊으면 아래가 한 그룹이 돼
|
|
||||||
`*` 의 규칙이 Yeti 에도 붙는다 — 실제로 그렇게 잘못 읽었다.
|
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Disallow: /assets
|
|
||||||
User-agent: Yeti ← 여기서 새 그룹
|
|
||||||
Disallow: /
|
|
||||||
"""
|
|
||||||
out: dict[str, list[str]] = {}
|
|
||||||
agents: list[str] = []
|
|
||||||
after_rule = False
|
|
||||||
for raw in body.splitlines():
|
|
||||||
line = raw.split("#", 1)[0].strip()
|
|
||||||
if not line:
|
|
||||||
agents, after_rule = [], False
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
key, value = key.strip().lower(), value.strip()
|
|
||||||
if key == "user-agent":
|
|
||||||
if after_rule:
|
|
||||||
agents, after_rule = [], False
|
|
||||||
agents.append(value)
|
|
||||||
out.setdefault(value, [])
|
|
||||||
elif key in ("disallow", "allow") and agents:
|
|
||||||
after_rule = True
|
|
||||||
if key == "disallow":
|
|
||||||
for a in agents:
|
|
||||||
out.setdefault(a, []).append(value)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _disallows_for(blocks: dict[str, list[str]], bot: str) -> list[str]:
|
|
||||||
"""그 봇에 적용되는 Disallow. 이름 규칙이 있으면 그것만, 없으면 `*` 를 따른다."""
|
|
||||||
for name, rules in blocks.items():
|
|
||||||
if name.lower() == bot.lower():
|
|
||||||
return rules
|
|
||||||
return blocks.get("*", [])
|
|
||||||
|
|
||||||
|
|
||||||
def judge(body: str) -> list[tuple[str, str, str]]:
|
|
||||||
"""`(status, label, detail)` 목록. status 는 checks.py 와 같은 문자열을 쓴다.
|
|
||||||
|
|
||||||
★ 여기서 `Finding` 을 만들지 않는다 — 이 모듈이 checks 를 import 하면 두 파일이
|
|
||||||
서로를 부르게 된다. 판정 결과만 돌려주고 `Finding` 조립은 부르는 쪽이 한다."""
|
|
||||||
out: list[tuple[str, str, str]] = []
|
|
||||||
blocks = _blocks(body)
|
|
||||||
|
|
||||||
missing = [b for b in NAVER_BOTS if not any(n.lower() == b.lower() for n in blocks)]
|
|
||||||
if missing:
|
|
||||||
out.append(("warn", "네이버 로봇 명시 허용", f"이름이 없다: {', '.join(missing)} — 와일드카드에 기댄다"))
|
|
||||||
else:
|
|
||||||
out.append(("ok", "네이버 로봇 명시 허용", " · ".join(NAVER_BOTS)))
|
|
||||||
|
|
||||||
for bot in NAVER_BOTS:
|
|
||||||
rules = _disallows_for(blocks, bot)
|
|
||||||
if "/" in rules:
|
|
||||||
out.append(("fail", f"{bot} 수집 차단", "`Disallow: /` — 이 호스트 전체가 막혀 있다"))
|
|
||||||
continue
|
|
||||||
hit = [r for r in rules if any(r.startswith(p) for p in ASSET_PREFIXES)]
|
|
||||||
if hit:
|
|
||||||
out.append((
|
|
||||||
"fail", f"{bot} 자산 차단",
|
|
||||||
f"JS·CSS 경로를 막고 있다({', '.join(hit)}) — 네이버는 그 페이지를 통째로 안 가져간다",
|
|
||||||
))
|
|
||||||
|
|
||||||
if re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body):
|
|
||||||
loc = re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body).group(1)
|
|
||||||
out.append(("ok", "사이트맵 지시", loc))
|
|
||||||
else:
|
|
||||||
out.append(("fail", "사이트맵 지시", "없다 — 크롤러가 사이트맵 위치를 알 방법이 없다"))
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_and_judge(client: httpx.AsyncClient, origin: str) -> list[tuple[str, str, str]]:
|
|
||||||
res = await get(client, origin.rstrip("/") + "/robots.txt")
|
|
||||||
if res is None:
|
|
||||||
return [("fail", "루트 robots.txt", "연결하지 못했다")]
|
|
||||||
if res.status_code != 200:
|
|
||||||
return [("fail", "루트 robots.txt", f"HTTP {res.status_code} — 크롤러가 읽는 유일한 자리다")]
|
|
||||||
return [("ok", "루트 robots.txt", f"{len(res.text)}바이트"), *judge(res.text)]
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
"""네이버 웹문서 검색 — 우리 사이트가 네이버에 잡히는지 보는 데만 쓴다.
|
|
||||||
|
|
||||||
★ **왜 `services/external/naver.py` 가 아니라 여기인가.**
|
|
||||||
그 파일이 지역검색 호출기이고 자격증명·오류 규칙·**쿼터 카운터**를 이미 갖고 있어서
|
|
||||||
웹문서검색도 거기 얹는 것이 맞다. 다만 **백엔드 코드는 고치지 않는다**는 제약이 있어
|
|
||||||
(2026-09-11) 이 모듈 안에 따로 둔다.
|
|
||||||
|
|
||||||
⚠️ 그 대가가 하나 있다: **쿼터는 하나인데 카운터가 둘이다.** 네이버 검색 API 는
|
|
||||||
애플리케이션당 일 25,000회이고 지역검색과 웹문서검색이 그 한도를 **공유**한다.
|
|
||||||
백엔드의 `services.external.naver.call_counts()` 에는 여기 호출이 **들어가지 않는다** —
|
|
||||||
그 값만 보고 "아직 여유 있다" 고 판단하면 틀린다. 합계가 필요하면 `call_count()` 를
|
|
||||||
같이 읽어야 한다.
|
|
||||||
→ 백엔드를 고칠 수 있게 되면 `NaverLocalClient.search_web()` 으로 옮기고 이 파일을 지운다.
|
|
||||||
|
|
||||||
★ 자격증명은 백엔드 설정 객체를 **읽어** 쓴다(고치지 않는다). env 이름을 두 번 적으면
|
|
||||||
한쪽만 바뀌는 날이 온다.
|
|
||||||
|
|
||||||
★ 공식 API 다. 네이버 검색창을 긁지 않는다 — 봇 탐지 우회는 영구 금지(docs/DECISIONS.md 1-1).
|
|
||||||
"""
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config.server_configs import external_api_config
|
|
||||||
|
|
||||||
WEBKR_URL = "https://openapi.naver.com/v1/search/webkr.json"
|
|
||||||
# 규격 상한. 지역검색의 "5건" 제약은 여기 없다.
|
|
||||||
MAX_DISPLAY = 100
|
|
||||||
TIMEOUT_SEC = 10.0
|
|
||||||
|
|
||||||
_CALL_COUNTS: Counter = Counter()
|
|
||||||
|
|
||||||
|
|
||||||
class NaverWebSearchUnavailable(RuntimeError):
|
|
||||||
"""자격증명이 없거나 호출이 실패했다 — 이 점검만 건너뛴다(발행과 무관)."""
|
|
||||||
|
|
||||||
|
|
||||||
def call_count() -> int:
|
|
||||||
"""이 프로세스가 웹문서검색을 부른 횟수.
|
|
||||||
|
|
||||||
★ 백엔드의 `call_counts()` 와 **합쳐서** 봐야 쿼터 실사용이 나온다(머리주석)."""
|
|
||||||
return _CALL_COUNTS["webkr"]
|
|
||||||
|
|
||||||
|
|
||||||
def enabled() -> bool:
|
|
||||||
cfg = external_api_config
|
|
||||||
return bool(cfg.naver_client_id and cfg.naver_client_secret)
|
|
||||||
|
|
||||||
|
|
||||||
async def search_web(query: str, display: int = 10, *, client: httpx.AsyncClient | None = None) -> list[dict]:
|
|
||||||
"""항목을 **그대로** 돌려준다(`title` `link` `description`).
|
|
||||||
|
|
||||||
★ 한계를 먼저 적는다: **이 API 의 결과는 네이버 통합검색 색인과 같지 않다.**
|
|
||||||
잡히면 색인된 것이 확실하지만, 안 잡혀도 "색인 안 됨" 이라고 단정할 수 없다.
|
|
||||||
확정 판정은 서치어드바이저에 등록해야 볼 수 있다(docs/DEPLOY.md 2-2단계).
|
|
||||||
→ 부르는 쪽은 없을 때 실패가 아니라 **미확정**으로 다뤄야 한다.
|
|
||||||
"""
|
|
||||||
cfg = external_api_config
|
|
||||||
if not enabled():
|
|
||||||
raise NaverWebSearchUnavailable("NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 가 설정되지 않았다")
|
|
||||||
|
|
||||||
params = {"query": query, "display": max(1, min(display, MAX_DISPLAY))}
|
|
||||||
headers = {
|
|
||||||
"X-Naver-Client-Id": cfg.naver_client_id,
|
|
||||||
"X-Naver-Client-Secret": cfg.naver_client_secret,
|
|
||||||
}
|
|
||||||
_CALL_COUNTS["webkr"] += 1
|
|
||||||
|
|
||||||
own = client is None
|
|
||||||
http = client or httpx.AsyncClient(timeout=httpx.Timeout(TIMEOUT_SEC, connect=5.0))
|
|
||||||
try:
|
|
||||||
res = await http.get(WEBKR_URL, params=params, headers=headers)
|
|
||||||
except httpx.HTTPError as ex:
|
|
||||||
raise NaverWebSearchUnavailable(f"웹문서검색 요청 실패: {type(ex).__name__}: {ex}") from ex
|
|
||||||
finally:
|
|
||||||
if own:
|
|
||||||
await http.aclose()
|
|
||||||
|
|
||||||
if res.status_code in (401, 403):
|
|
||||||
# 키가 있지만 잘못됐거나 권한이 없다 — 설정 문제라 재시도해도 소용없다.
|
|
||||||
raise NaverWebSearchUnavailable(
|
|
||||||
f"웹문서검색 인증 실패({res.status_code}) — 클라이언트 ID/Secret 과 검색 API "
|
|
||||||
f"사용 설정을 확인해라: {res.text[:200]}"
|
|
||||||
)
|
|
||||||
if res.status_code != 200:
|
|
||||||
raise NaverWebSearchUnavailable(f"웹문서검색 응답 오류 status={res.status_code} body={res.text[:200]}")
|
|
||||||
try:
|
|
||||||
return list(res.json().get("items") or [])
|
|
||||||
except ValueError as ex:
|
|
||||||
raise NaverWebSearchUnavailable(f"웹문서검색 응답 파싱 실패: {ex}") from ex
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""네이버 탐색 준비 상태를 **사람이 읽게** 찍는다.
|
|
||||||
|
|
||||||
레포 루트에서 (백엔드 venv 를 쓴다 — httpx·pydantic 이 거기 있다):
|
|
||||||
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --place "스테이,머뭄"
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --slug butter
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --json (도구에 물릴 때)
|
|
||||||
|
|
||||||
★ **컨테이너 안에서는 못 돈다.** `geo/` 는 백엔드 이미지에 들어가지 않는다 — 넣으려면
|
|
||||||
`solution/backend/Dockerfile` 을 고쳐야 하고, 백엔드 코드는 고치지 않기로 했다(2026-09-11).
|
|
||||||
서버에서도 레포 체크아웃 + 백엔드 venv 로 돌린다. 정기 실행이 필요해지면 `geo/Dockerfile`
|
|
||||||
로 자기 이미지를 갖는 것이 이 제약 아래서의 길이다(geo/README.md).
|
|
||||||
|
|
||||||
★ 판정은 여기서 하지 않는다 — `geo.naver.probe()` 가 한다. 이 파일은 그 결과를 줄로 바꾸는
|
|
||||||
일만 한다. 판정을 화면 코드에 두면 라우터가 붙을 때 기준이 둘로 갈린다.
|
|
||||||
|
|
||||||
★ 오리진 기본값은 `site_payload.publish_origin()` 이다. 발행 호스트를 새 env 로 또 두면
|
|
||||||
canonical 과 갈린다(AGENTS.md '발행 호스트는 두 곳에 있고 같아야 한다').
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# ★ 두 자리를 얹는다: 레포 루트(`geo` 패키지) + `solution/backend`(도메인 코드).
|
|
||||||
# admin/backend 가 PYTHONPATH 로 같은 일을 한다 — geo 도 백엔드 코드를 복제하지 않는다.
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
for _path in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
|
|
||||||
if _path not in sys.path:
|
|
||||||
sys.path.insert(0, _path)
|
|
||||||
# ★ APP_ENV=test 면 .env 를 읽지 않는다(실키가 테스트로 새는 경로를 막아 뒀다).
|
|
||||||
# 이 스크립트는 실제 네이버 API 를 부르므로 local 이어야 한다 — 다른 스크립트도 같다.
|
|
||||||
os.environ.setdefault("APP_ENV", "local")
|
|
||||||
|
|
||||||
from geo.naver import FAIL, OK, SKIP, WARN, probe # noqa: E402
|
|
||||||
from services.site_payload import publish_origin # noqa: E402
|
|
||||||
|
|
||||||
MARK = {OK: " ✓", WARN: " !", FAIL: " ✗", SKIP: " ·"}
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="네이버 탐색 준비 상태 점검")
|
|
||||||
parser.add_argument("origin", nargs="?", default=None, help=f"확인할 오리진(기본 {publish_origin()})")
|
|
||||||
parser.add_argument("--place", help="상호명 — 색인·역방향 링크 점검에 쓴다")
|
|
||||||
parser.add_argument("--slug", help="통보 경로를 확인할 사이트(기본: 사이트맵의 첫 사이트)")
|
|
||||||
parser.add_argument("--json", action="store_true", help="사람이 아니라 도구가 읽을 형태로")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
report = await probe(
|
|
||||||
args.origin or publish_origin(),
|
|
||||||
place_name=args.place,
|
|
||||||
slug=args.slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
|
|
||||||
return 0 if report.ok else 1
|
|
||||||
|
|
||||||
print(f"[네이버 점검] {report.origin}\n")
|
|
||||||
for f in report.findings:
|
|
||||||
print(f"{MARK[f.status]} {f.label}" + (f" — {f.detail}" if f.detail else ""))
|
|
||||||
|
|
||||||
passed = [f for f in report.findings if f.status == OK]
|
|
||||||
print(f"\n[결과] 통과 {len(passed)} · 주의 {len(report.warned)} · 실패 {len(report.failed)}")
|
|
||||||
for title, rows in (("먼저 고칠 것", report.failed), ("확인이 필요한 것", report.warned)):
|
|
||||||
if not rows:
|
|
||||||
continue
|
|
||||||
print(f"\n{title}:")
|
|
||||||
for f in rows:
|
|
||||||
print(f" - {f.label}: {f.detail}" + (f"\n → {f.fix}" if f.fix else ""))
|
|
||||||
return 0 if report.ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(asyncio.run(main()))
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
"""**발행한 뒤에** — 알리고, 제대로 나갔는지 본다.
|
|
||||||
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug>
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --place "스테이,머뭄"
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/postflight.py --all (사이트맵의 전부)
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --dry-run
|
|
||||||
|
|
||||||
★★ **왜 "발행 전" 이 아니라 "발행 후" 인가.** 아직 없는 주소를 통보하면 검색엔진이 404 를
|
|
||||||
받는다. 알리지 않은 것보다 나쁘다 — 헛주소를 보내는 호스트로 기록된다.
|
|
||||||
그래서 통보는 **구워진 것을 확인한 뒤**에만 보낸다(`_live` 가 그 확인이다).
|
|
||||||
|
|
||||||
★ 순서가 뜻을 갖는다:
|
|
||||||
1) 살아 있나 200 이 아니면 통보하지 않는다
|
|
||||||
2) 알린다 루트 사이트맵에서 이 사이트 주소를 골라 IndexNow 로
|
|
||||||
3) 기록한다 **성공한 것만.** 실패를 성공으로 기억하면 영영 다시 안 보낸다
|
|
||||||
4) 본다 소유확인·색인·역방향 링크 (선택)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
os.environ.setdefault("APP_ENV", "local")
|
|
||||||
|
|
||||||
import httpx # noqa: E402
|
|
||||||
|
|
||||||
from geo import state # noqa: E402
|
|
||||||
from geo.naver import notify # noqa: E402
|
|
||||||
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
|
|
||||||
from services.site_payload import publish_origin # noqa: E402
|
|
||||||
|
|
||||||
STATE_NAME = "indexnow"
|
|
||||||
|
|
||||||
|
|
||||||
async def _live(client: httpx.AsyncClient, url: str) -> bool:
|
|
||||||
"""통보 전에 실제로 열리는지 본다. **404 통보를 막는 유일한 방어다.**"""
|
|
||||||
res = await get(client, url)
|
|
||||||
return res is not None and res.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
async def run(origin: str, slugs: list[str] | None, *, dry_run: bool = False) -> list[dict]:
|
|
||||||
origin = origin.rstrip("/")
|
|
||||||
results: list[dict] = []
|
|
||||||
seen = state.load(STATE_NAME)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
|
|
||||||
locs = await notify.root_sitemap_locs(client, origin)
|
|
||||||
if not locs:
|
|
||||||
return [{"slug": None, "ok": False, "error": "루트 사이트맵을 읽지 못했다"}]
|
|
||||||
|
|
||||||
if not slugs:
|
|
||||||
# 사이트맵에서 slug 를 뽑는다 — 주소 규칙을 여기서 다시 만들지 않는다.
|
|
||||||
slugs = sorted({loc.split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
|
|
||||||
for loc in locs if "/s/" in loc})
|
|
||||||
|
|
||||||
for slug in slugs:
|
|
||||||
urls = notify.site_urls(locs, slug)
|
|
||||||
if not urls:
|
|
||||||
results.append({"slug": slug, "ok": False, "error": "사이트맵에 이 사이트 주소가 없다"})
|
|
||||||
continue
|
|
||||||
if not await _live(client, urls[0]):
|
|
||||||
results.append({"slug": slug, "ok": False, "error": f"{urls[0]} 이 아직 200 이 아니다 — 통보하지 않는다"})
|
|
||||||
continue
|
|
||||||
if dry_run:
|
|
||||||
results.append({"slug": slug, "ok": True, "urls": urls, "dry_run": True})
|
|
||||||
continue
|
|
||||||
|
|
||||||
res = await notify.notify_site(client, origin, slug, locs=locs)
|
|
||||||
row = res.as_dict()
|
|
||||||
results.append(row)
|
|
||||||
if res.ok:
|
|
||||||
# ★ 성공한 것만 기록한다(머리주석 3번).
|
|
||||||
seen[slug] = {"at": datetime.now(timezone.utc).isoformat(), "urls": len(res.urls)}
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
state.save(STATE_NAME, seen)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="발행 후 — 알리고 확인한다")
|
|
||||||
parser.add_argument("slug", nargs="*", help="알릴 사이트(여럿 가능). 비우면 --all 이 필요하다")
|
|
||||||
parser.add_argument("--all", action="store_true", help="사이트맵에 있는 전부")
|
|
||||||
parser.add_argument("--origin", default=None)
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="보내지 않고 무엇을 보낼지만 본다")
|
|
||||||
parser.add_argument("--json", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not args.slug and not args.all:
|
|
||||||
parser.error("slug 를 주거나 --all 을 써라")
|
|
||||||
|
|
||||||
origin = args.origin or publish_origin()
|
|
||||||
results = await run(origin, args.slug or None, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
||||||
else:
|
|
||||||
head = "[발행 후] " + origin + (" (dry-run)" if args.dry_run else "")
|
|
||||||
print(head + "\n")
|
|
||||||
for r in results:
|
|
||||||
mark = " ✓" if r.get("ok") else " ✗"
|
|
||||||
n = len(r.get("urls") or [])
|
|
||||||
detail = r.get("error") or f"URL {n}개" + (f" · HTTP {r['status']}" if r.get("status") else "")
|
|
||||||
print(f"{mark} {r.get('slug')} — {detail}")
|
|
||||||
bad = [r for r in results if not r.get("ok")]
|
|
||||||
print(f"\n[결과] 통보 {len(results) - len(bad)} · 실패 {len(bad)}")
|
|
||||||
return 1 if any(not r.get("ok") for r in results) else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(asyncio.run(main()))
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
"""**발행하기 전에** — 지금 발행하면 네이버에 닿는가.
|
|
||||||
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/preflight.py
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/preflight.py --json
|
|
||||||
|
|
||||||
★ 사이트 하나를 보는 게 아니라 **통로**를 본다. 오리진이 하나라서 여기서 한 번 확인하면
|
|
||||||
`/s/<slug>` 전부에 해당한다 — 사장님이 1,000명이 돼도 반복하지 않는다.
|
|
||||||
|
|
||||||
★ 여기서 빨간불이면 **발행해도 네이버에 안 닿는다.** 2주 뒤에 "왜 색인이 안 되지" 로
|
|
||||||
알게 되는 것과, 발행 전에 아는 것의 차이다.
|
|
||||||
|
|
||||||
점검 넷:
|
|
||||||
1. 소유확인 파일 없으면 서치어드바이저에 등록조차 못 한다
|
|
||||||
2. robots.txt Yeti·Daumoa 허용 · 사이트맵 지시 · ★ JS·CSS 를 막지 않았나
|
|
||||||
3. 루트 사이트맵 통보할 URL 목록의 출처다
|
|
||||||
4. IndexNow 키 파일 ★ 없으면 통보가 403 으로 전부 거절된다
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
os.environ.setdefault("APP_ENV", "local")
|
|
||||||
|
|
||||||
import httpx # noqa: E402
|
|
||||||
|
|
||||||
from geo.naver import FAIL, OK, SKIP, WARN, Finding, NaverEoReport # noqa: E402
|
|
||||||
from geo.naver import checks, notify, robots # noqa: E402
|
|
||||||
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
|
|
||||||
from services.site_payload import publish_origin # noqa: E402
|
|
||||||
|
|
||||||
MARK = {OK: " ✓", WARN: " !", FAIL: " ✗", SKIP: " ·"}
|
|
||||||
|
|
||||||
|
|
||||||
async def preflight(origin: str) -> NaverEoReport:
|
|
||||||
report = NaverEoReport(origin=origin.rstrip("/"))
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
|
|
||||||
report.findings.append(await checks.check_verification(client, report.origin))
|
|
||||||
|
|
||||||
for status, label, detail in await robots.fetch_and_judge(client, report.origin):
|
|
||||||
report.findings.append(Finding("robots", label, status, detail))
|
|
||||||
|
|
||||||
res = await get(client, report.origin + "/sitemap.xml")
|
|
||||||
if res is not None and res.status_code == 200 and "<urlset" in res.text:
|
|
||||||
n = res.text.count("<loc>")
|
|
||||||
report.findings.append(Finding("root_sitemap", "루트 사이트맵", OK, f"URL {n}개"))
|
|
||||||
else:
|
|
||||||
code = res.status_code if res else "연결실패"
|
|
||||||
report.findings.append(Finding(
|
|
||||||
"root_sitemap", "루트 사이트맵", FAIL, f"HTTP {code} — 통보할 URL 의 출처가 없다",
|
|
||||||
))
|
|
||||||
|
|
||||||
ok, detail = await notify.check_key_file(client, report.origin)
|
|
||||||
report.findings.append(Finding(
|
|
||||||
"indexnow_key", "IndexNow 키 파일",
|
|
||||||
OK if ok else (WARN if not notify.key() else FAIL), detail,
|
|
||||||
None if ok else "이게 없으면 통보가 403 으로 전부 거절된다",
|
|
||||||
))
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="발행 전 — 네이버로 가는 통로가 뚫렸나")
|
|
||||||
parser.add_argument("origin", nargs="?", default=None)
|
|
||||||
parser.add_argument("--json", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
report = await preflight(args.origin or publish_origin())
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
|
|
||||||
return 0 if report.ok else 1
|
|
||||||
|
|
||||||
print(f"[발행 전 점검] {report.origin}\n")
|
|
||||||
for f in report.findings:
|
|
||||||
print(f"{MARK[f.status]} {f.label}" + (f" — {f.detail}" if f.detail else ""))
|
|
||||||
print(f"\n[결과] 통과 {len([f for f in report.findings if f.status == OK])} · "
|
|
||||||
f"주의 {len(report.warned)} · 실패 {len(report.failed)}")
|
|
||||||
if report.failed:
|
|
||||||
print("\n★ 지금 발행하면 네이버에 닿지 않는다. 먼저 고칠 것:")
|
|
||||||
for f in report.failed:
|
|
||||||
print(f" - {f.label}: {f.detail}" + (f"\n → {f.fix}" if f.fix else ""))
|
|
||||||
return 0 if report.ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(asyncio.run(main()))
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
"""발행을 **밖에서 알아채서** 알린다.
|
|
||||||
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/watch.py (계속 돈다)
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/watch.py --once (한 번만)
|
|
||||||
solution/backend/.venv/bin/python geo/scripts/watch.py --interval 300
|
|
||||||
|
|
||||||
★ **어떻게 알아채나 — 루트 사이트맵의 `<lastmod>` 를 본다.** 지난번에 본 것과 달라진
|
|
||||||
사이트만 통보한다. 우리 내부(DB·잡 큐·볼륨)를 들여다보지 않는다 — **크롤러가 발행을
|
|
||||||
알아채는 방식과 같다.** 그래서 `solution` 을 한 줄도 고치지 않고 끼어들 수 있고,
|
|
||||||
"밖에서 본다" 는 이 모듈의 성격도 유지된다.
|
|
||||||
|
|
||||||
★ 대가: **즉시가 아니다.** 다음 확인 때 알린다(기본 5분). 프리렌더도 2초 폴링으로 도니
|
|
||||||
같은 종류의 지연이고, 색인은 어차피 분 단위가 아니다.
|
|
||||||
더 빨라야 하면 `site-out` 볼륨의 `payloads/.status/` 를 감시하는 방법이 있는데,
|
|
||||||
그러면 내부 파일 구조에 묶여 "밖에서 본다" 가 깨진다. 그 값이 지금은 없다고 봤다.
|
|
||||||
|
|
||||||
★ 첫 실행은 **전부 새 것으로 보인다.** 그대로 두면 사이트 1,000개를 한꺼번에 통보한다 —
|
|
||||||
`--seed` 로 "지금 상태를 이미 아는 것으로" 기록만 하고 넘어갈 수 있다.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
os.environ.setdefault("APP_ENV", "local")
|
|
||||||
|
|
||||||
import httpx # noqa: E402
|
|
||||||
|
|
||||||
from geo import state # noqa: E402
|
|
||||||
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
|
|
||||||
from services.site_payload import publish_origin # noqa: E402
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
from postflight import run as postflight_run # noqa: E402
|
|
||||||
|
|
||||||
STATE_NAME = "seen-sitemap"
|
|
||||||
DEFAULT_INTERVAL = 300.0
|
|
||||||
|
|
||||||
# `<url><loc>…</loc><lastmod>…</lastmod></url>` 한 덩이씩. lastmod 는 없을 수 있다.
|
|
||||||
_URL_BLOCK = re.compile(r"<url>(.*?)</url>", re.S)
|
|
||||||
_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")
|
|
||||||
_LASTMOD = re.compile(r"<lastmod>\s*([^<\s]+)\s*</lastmod>")
|
|
||||||
|
|
||||||
|
|
||||||
async def snapshot(client: httpx.AsyncClient, origin: str) -> dict[str, str]:
|
|
||||||
"""`{slug: lastmod}`. lastmod 가 없으면 loc 자체를 값으로 둔다(있고 없고만 본다)."""
|
|
||||||
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
|
|
||||||
if res is None or res.status_code != 200:
|
|
||||||
return {}
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for block in _URL_BLOCK.findall(res.text):
|
|
||||||
loc_m = _LOC.search(block)
|
|
||||||
if not loc_m or "/s/" not in loc_m.group(1):
|
|
||||||
continue
|
|
||||||
slug = loc_m.group(1).split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
|
|
||||||
if not slug:
|
|
||||||
continue
|
|
||||||
mod = _LASTMOD.search(block)
|
|
||||||
out[slug] = mod.group(1) if mod else loc_m.group(1)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def changed(previous: dict, current: dict[str, str]) -> list[str]:
|
|
||||||
"""새로 생겼거나 lastmod 가 달라진 slug."""
|
|
||||||
return sorted(s for s, mod in current.items() if previous.get(s) != mod)
|
|
||||||
|
|
||||||
|
|
||||||
async def tick(origin: str, *, seed: bool = False, verbose: bool = True) -> list[str]:
|
|
||||||
seen = state.load(STATE_NAME)
|
|
||||||
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
|
|
||||||
current = await snapshot(client, origin)
|
|
||||||
if not current:
|
|
||||||
if verbose:
|
|
||||||
print(f"[{_now()}] 사이트맵을 읽지 못했다 — 다음 차례에 다시 본다")
|
|
||||||
return []
|
|
||||||
|
|
||||||
todo = changed(seen, current)
|
|
||||||
if seed:
|
|
||||||
state.save(STATE_NAME, current)
|
|
||||||
if verbose:
|
|
||||||
print(f"[{_now()}] 현재 {len(current)}개를 '이미 아는 것'으로 기록했다 (통보하지 않음)")
|
|
||||||
return []
|
|
||||||
if not todo:
|
|
||||||
if verbose:
|
|
||||||
print(f"[{_now()}] 바뀐 것 없음 (사이트 {len(current)}개)")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(f"[{_now()}] 바뀐 사이트 {len(todo)}개 → 통보: {', '.join(todo[:10])}"
|
|
||||||
+ (" …" if len(todo) > 10 else ""))
|
|
||||||
results = await postflight_run(origin, todo)
|
|
||||||
|
|
||||||
# ★ 통보에 성공한 것만 '봤다' 고 기록한다. 실패를 기록하면 영영 다시 안 보낸다.
|
|
||||||
for r in results:
|
|
||||||
if r.get("ok") and r.get("slug") in current:
|
|
||||||
seen[r["slug"]] = current[r["slug"]]
|
|
||||||
if verbose:
|
|
||||||
mark = " ✓" if r.get("ok") else " ✗"
|
|
||||||
detail = r.get("error") or f"URL {len(r.get('urls') or [])}개"
|
|
||||||
print(f"{mark} {r.get('slug')} — {detail}")
|
|
||||||
state.save(STATE_NAME, seen)
|
|
||||||
return [r["slug"] for r in results if r.get("ok")]
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="발행을 알아채서 알린다")
|
|
||||||
parser.add_argument("--origin", default=None)
|
|
||||||
parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="초 (기본 300)")
|
|
||||||
parser.add_argument("--once", action="store_true", help="한 번만 보고 끝낸다")
|
|
||||||
parser.add_argument("--seed", action="store_true",
|
|
||||||
help="지금 상태를 '이미 아는 것'으로 기록만 한다 — 첫 실행의 대량 통보를 막는다")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
origin = args.origin or publish_origin()
|
|
||||||
print(f"[watch] {origin} · {args.interval:.0f}초마다")
|
|
||||||
if args.once or args.seed:
|
|
||||||
await tick(origin, seed=args.seed)
|
|
||||||
return 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await tick(origin)
|
|
||||||
except Exception as ex: # 한 번 실패로 감시가 멈추면 안 된다
|
|
||||||
print(f"[{_now()}] 예외를 삼킨다 — {type(ex).__name__}: {ex}")
|
|
||||||
await asyncio.sleep(args.interval)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
sys.exit(asyncio.run(main()))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[watch] 종료")
|
|
||||||
47
geo/state.py
47
geo/state.py
@ -1,47 +0,0 @@
|
|||||||
"""무엇을 이미 알렸는지 기억한다 — **DB 가 아니라 파일이다.**
|
|
||||||
|
|
||||||
★ 표를 만들지 않은 이유: 이 모듈은 `solution/backend` 를 고치지 않는다는 제약 아래 있고
|
|
||||||
(geo/README.md '제약'), 스키마는 `init.sql` 과 마이그레이션 **양쪽**을 고쳐야 한다.
|
|
||||||
그리고 여기 담기는 건 "마지막으로 통보한 시각" 하나라, 표가 필요해지는 종류가 아니다.
|
|
||||||
추이·리포트가 요구사항이 되면 그때 표를 정한다.
|
|
||||||
|
|
||||||
★ 잃어도 치명적이지 않게 설계한다. 파일이 사라지면 **전부 다시 통보**할 뿐이다 —
|
|
||||||
같은 URL 을 다시 알리는 건 규격상 정상이고(변경 통보), 손해는 호출 몇 번이다.
|
|
||||||
반대로 "안 알린 것을 알렸다고 기억" 하는 쪽이 위험해서, **통보 성공 뒤에만 기록한다.**
|
|
||||||
|
|
||||||
★ 자리는 `GEO_STATE_DIR`. 기본은 `geo/state/` 이고 git 에 올리지 않는다.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
STATE_DIR_ENV = "GEO_STATE_DIR"
|
|
||||||
|
|
||||||
|
|
||||||
def state_dir() -> Path:
|
|
||||||
raw = os.environ.get(STATE_DIR_ENV, "").strip()
|
|
||||||
return Path(raw) if raw else Path(__file__).resolve().parent / "state"
|
|
||||||
|
|
||||||
|
|
||||||
def _path(name: str) -> Path:
|
|
||||||
return state_dir() / f"{name}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load(name: str) -> dict:
|
|
||||||
path = _path(name)
|
|
||||||
if not path.is_file():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text("utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
# 깨진 파일 때문에 통보가 멈추면 안 된다 — 빈 상태로 보고 다시 알린다.
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save(name: str, data: dict) -> None:
|
|
||||||
path = _path(name)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
# 같은 디렉토리에 쓰고 바꿔치기한다 — 쓰는 중에 죽어도 반쪽 파일이 남지 않는다.
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
|
|
||||||
tmp.replace(path)
|
|
||||||
@ -27,19 +27,18 @@ COPY admin ./admin
|
|||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ARG VITE_PUBLISH_HOST
|
ARG VITE_PUBLISH_HOST
|
||||||
ARG VITE_SITE_PREVIEW_URL
|
ARG VITE_SITE_PREVIEW_URL
|
||||||
# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 —
|
|
||||||
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession).
|
|
||||||
ARG VITE_AUTO_LOGIN_ID
|
|
||||||
ARG VITE_AUTO_LOGIN_PW
|
|
||||||
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
|
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
|
||||||
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
|
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
|
||||||
ARG VITE_GOOGLE_CLIENT_ID
|
ARG VITE_GOOGLE_CLIENT_ID
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||||
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
|
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
|
||||||
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
|
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
|
||||||
VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \
|
|
||||||
VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW \
|
|
||||||
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
|
# ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는
|
||||||
|
# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나
|
||||||
|
# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev,
|
||||||
|
# vite dev)에만 있다 — 그쪽은 이 Dockerfile 을 타지 않는다(lib/autoSession.ts 의 DEV 가드도
|
||||||
|
# 같은 이유로 있다 — 이 ARG 가 실수로 되돌아와도 프로덕션 빌드에서는 죽은 코드가 된다).
|
||||||
RUN npm run build -w @o2o/frontend
|
RUN npm run build -w @o2o/frontend
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@ -43,6 +43,22 @@ server {
|
|||||||
application/javascript application/json application/xml
|
application/javascript application/json application/xml
|
||||||
image/svg+xml;
|
image/svg+xml;
|
||||||
|
|
||||||
|
# 승인 nonce 가 액세스 로그·Referer·검색 색인으로 새지 않게 이 자리만 따로 준다.
|
||||||
|
#
|
||||||
|
# ★ `try_files` 를 쓰면 헤더가 사라진다. try_files 의 폴백은 **내부 리다이렉트**라
|
||||||
|
# 요청이 이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는
|
||||||
|
# 아래 add_header 가 하나도 붙지 않는다(실측 2026-09-14: 200 은 뜨는데 헤더만 없다).
|
||||||
|
# `rewrite ... break` 는 같은 블록 안에 머문다 — 그래서 이 모양이어야 한다.
|
||||||
|
# ★ 승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다. 곧바로 셸을 준다.
|
||||||
|
location ^~ /approve/ {
|
||||||
|
root /srv/app;
|
||||||
|
access_log off;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
rewrite ^ /__spa-fallback.html break;
|
||||||
|
}
|
||||||
|
|
||||||
# ── 발행 사이트 ────────────────────────────────────────────
|
# ── 발행 사이트 ────────────────────────────────────────────
|
||||||
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
||||||
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
||||||
@ -123,6 +139,21 @@ server {
|
|||||||
|
|
||||||
# ── API ────────────────────────────────────────────────────
|
# ── API ────────────────────────────────────────────────────
|
||||||
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
|
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
|
||||||
|
location ^~ /v1/social/ {
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
proxy_pass $api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
|
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
|
||||||
proxy_pass $api;
|
proxy_pass $api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@ -141,31 +172,6 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 네이버 서치어드바이저 소유확인 ─────────────────────────────
|
|
||||||
# 등록할 때 이 블록의 주석을 풀고 <토큰> 을 서치어드바이저가 준 파일명(.html 제외)으로
|
|
||||||
# 바꾼다. 두 자리를 같은 값으로 바꿔야 한다(location 경로 · 응답 본문).
|
|
||||||
#
|
|
||||||
# ★ **여기서 내주는 이유.** 네이버는 DNS TXT 를 안 받는다. 남은 건 메타태그와 파일인데,
|
|
||||||
# 메타태그는 랜딩 <head> 라 `solution/frontend` 를 고쳐야 하고 `VITE_*` 로 번들에
|
|
||||||
# 구워져 값을 바꿀 때마다 재빌드가 필요하다. 이 파일은 **바인드 마운트**라
|
|
||||||
# 고치고 reload 하면 끝이다.
|
|
||||||
#
|
|
||||||
# ★★ **이 블록이 없으면 404 가 아니라 빌더 앱 HTML 이 200 으로 나간다** — 루트의
|
|
||||||
# `*.html` 은 맨 아래 `location /` 의 SPA 폴백으로 떨어진다. 검색엔진은 "확인 실패" 만
|
|
||||||
# 뱉고 이유를 안 알려주는데, 눈으로는 파일이 있는 것처럼 보인다.
|
|
||||||
# → 확인은 상태코드가 아니라 **내용**으로 한다:
|
|
||||||
# geo/scripts/check_naver_eo.py 가 그 검사를 한다(루트 .env 의 NAVER_SITE_VERIFICATION
|
|
||||||
# 과 대조). 이 파일과 .env 두 곳에 같은 값이 사는 대가를 그 점검이 막는다.
|
|
||||||
#
|
|
||||||
# ★ 이 파일은 git 에 없다(`.example` 만 커밋된다). 서버를 새로 세우면 **다시 넣어야 한다** —
|
|
||||||
# 빼먹으면 며칠 뒤 소유확인이 조용히 풀린다(검색엔진이 주기적으로 재확인한다).
|
|
||||||
#
|
|
||||||
# location = /<토큰>.html {
|
|
||||||
# default_type text/html;
|
|
||||||
# add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
||||||
# return 200 'naver-site-verification: <토큰>.html';
|
|
||||||
# }
|
|
||||||
|
|
||||||
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
|
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
|
||||||
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
|
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
|
||||||
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
|
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
|
||||||
|
|||||||
5
ontology/.dockerignore
Normal file
5
ontology/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
37
ontology/.env.example
Normal file
37
ontology/.env.example
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# --- server ---
|
||||||
|
PORT=3100
|
||||||
|
|
||||||
|
# --- postgres (docker-compose 기본값) ---
|
||||||
|
DATABASE_URL=postgres://ontology:ontology@localhost:55432/ontology
|
||||||
|
|
||||||
|
# --- redis (BullMQ) ---
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=56379
|
||||||
|
|
||||||
|
# --- 임베딩 ---
|
||||||
|
# local : 로컬 multilingual-e5-small (384차원, 최초 1회 모델 다운로드 후 오프라인)
|
||||||
|
# mock : 문자 bigram 해싱 — 의미는 못 잡음
|
||||||
|
# openai : text-embedding-3-small (dimensions=384 로 요청)
|
||||||
|
EMBEDDING_PROVIDER=local
|
||||||
|
EMBEDDING_LOCAL_MODEL=Xenova/multilingual-e5-small
|
||||||
|
|
||||||
|
# --- LLM ---
|
||||||
|
# mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값)
|
||||||
|
# openai : 실제 OpenAI 호출
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=gpt-4.1-mini
|
||||||
|
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# --- 생성/중복제거 튜닝 ---
|
||||||
|
# 코사인 자동 병합 임계값. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본으로 나오므로
|
||||||
|
# 0.92 는 오병합을 부른다. 실측상 어순 변형만 0.999 대에 모이므로 0.99 로 둔다.
|
||||||
|
DEDUP_COSINE_THRESHOLD=0.99
|
||||||
|
# trigram 유사도 사전 필터
|
||||||
|
DEDUP_TRIGRAM_THRESHOLD=0.6
|
||||||
|
# 벡터 비교 대상 상위 후보 수
|
||||||
|
DEDUP_CANDIDATE_LIMIT=20
|
||||||
|
# 1회 생성 요청당 키워드 목표 개수
|
||||||
|
GENERATION_TARGET_KEYWORDS=15
|
||||||
|
# 주기 리프레시 간격(일)
|
||||||
|
REFRESH_INTERVAL_DAYS=30
|
||||||
8
ontology/.gitignore
vendored
Normal file
8
ontology/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump)
|
||||||
|
data/*.sql.gz
|
||||||
23
ontology/Dockerfile
Normal file
23
ontology/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# o2o-site-ontology — 발행 사이트의 메타 키워드를 주는 서비스.
|
||||||
|
# ★ 왜 이 레포 안에 있나 (2026-09-14) — 발행 파이프라인이 이걸 부르는데 따로 띄워 두면
|
||||||
|
# "코드는 올라갔는데 서버가 없어" 로 조용히 키워드 없이 발행된다. compose 한 벌로 같이 뜬다.
|
||||||
|
# ★ alpine 을 쓰지 않는다 (2026-09-14 실측). 임베딩 런타임(onnxruntime)이 musl 용 바이너리를
|
||||||
|
# 내주지 않아 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다.
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 먼저 — 소스만 바뀌면 이 층은 캐시된다.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 임베딩 모델(약 120MB)은 첫 실행에 받아 볼륨에 남긴다 — 이미지에 굽지 않는다.
|
||||||
|
ENV PORT=3100 \
|
||||||
|
TRANSFORMERS_CACHE=/app/.cache \
|
||||||
|
HF_HOME=/app/.cache
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
429
ontology/README.md
Normal file
429
ontology/README.md
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
# o2o-site-ontology
|
||||||
|
|
||||||
|
o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스.
|
||||||
|
|
||||||
|
- **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건)
|
||||||
|
- 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색
|
||||||
|
- 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔**
|
||||||
|
- 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만
|
||||||
|
|
||||||
|
## 빠른 시작 (로컬)
|
||||||
|
|
||||||
|
**필요한 것:** Docker Desktop 실행 중 · Node 20+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.o2o.kr/Web4ai/o2o-site-ontology.git
|
||||||
|
cd o2o-site-ontology
|
||||||
|
npm install
|
||||||
|
npm run setup # .env 생성 → 컨테이너 → 마이그레이션 → 시드 → 키워드 7,093건 적재
|
||||||
|
npm start # http://localhost:3100
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run setup` 이 전부 한다. API 키는 필요 없다 (LLM=mock, 임베딩=로컬 모델).
|
||||||
|
최초 1회 임베딩 모델을 내려받는다 — 약 120MB, 1~2분. 그 뒤로는 오프라인으로 동작한다.
|
||||||
|
|
||||||
|
포트는 기존 개발환경과 겹치지 않게 잡아 두었다 — postgres `55432`, redis `56379`, 앱 `3100`.
|
||||||
|
|
||||||
|
확인: **http://localhost:3100/demo** 입력창에 `스테이 머뭄`
|
||||||
|
|
||||||
|
엑셀 산출 스크립트를 쓸 때만 파이썬 의존성이 필요하다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install -r scripts/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
<details><summary>수동으로 단계별 실행</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm run db:up # postgres(pgvector) + redis
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed # 업종/지역 계층 + 데모 업체
|
||||||
|
npm run dataset:ingest # 군산 상세 974건
|
||||||
|
npm run dataset:ingest-nationwide # 전국 54개 지역
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
브라우저에서 **http://localhost:3100/demo** 를 열면 매칭 콘솔이 뜬다.
|
||||||
|
입력창에 `스테이 머뭄` 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고
|
||||||
|
적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다.
|
||||||
|
|
||||||
|
`npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다.
|
||||||
|
|
||||||
|
### 실제 OpenAI 로 전환
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LLM_PROVIDER=openai
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
OPENAI_MODEL=gpt-4.1-mini
|
||||||
|
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
```
|
||||||
|
|
||||||
|
`LLM_PROVIDER=mock` 은 문자 bigram 해싱 임베딩을 쓴다. 랜덤이 아니라 **비슷한 문자열이면
|
||||||
|
비슷한 벡터**가 나오므로 중복제거 파이프라인 검증에는 충분하지만, 의미 기반 중복
|
||||||
|
(`강남 미용실` ↔ `강남 헤어샵`) 은 실제 임베딩 모델에서만 잡힌다.
|
||||||
|
|
||||||
|
## 데이터 모델
|
||||||
|
|
||||||
|
| 테이블 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `industry` / `region` | `ltree` 업종·지역 계층. 상위 노드 키워드 상속의 기반 |
|
||||||
|
| `merchant` | 업체. `external_id` 가 o2o-site-AEO 의 사이트 ID |
|
||||||
|
| `keyword` | **전역** 키워드 사전. `normalized` 유니크, `aliases[]`, `embedding vector(1536)` |
|
||||||
|
| `merchant_keyword` | 업체 ↔ 키워드 연결. `relevance` / `status` / `impressions` / `ctr` |
|
||||||
|
| `qa_pair` | AEO 용 질문-답변 쌍 |
|
||||||
|
| `generation_run` | 생성 감사 로그 (프롬프트 버전·토큰·통계) |
|
||||||
|
|
||||||
|
키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만
|
||||||
|
저장되고, `강남 미용실` 을 쓰는 업체가 100곳이어도 중복제거가 성립한다.
|
||||||
|
|
||||||
|
## 시드 데이터 주의
|
||||||
|
|
||||||
|
`src/db/seed.ts` 의 업체 중 **스테이머뭄(site-3001)만 실재 업체**이고, 나머지(레브살롱·헤어랩·소담한상)는
|
||||||
|
동작 확인용 가상 업체다. 스테이머뭄 프로필도 공개 정보로 확인된 항목만 채웠고,
|
||||||
|
가격·바베큐·스파·주차·애견동반은 `profile.unverified` 에 남겨 두었다 — 사업자 확인 후 채울 것.
|
||||||
|
|
||||||
|
매칭 품질은 프로필 정확도에 그대로 좌우된다. 실제로 초기 시드에 잘못 들어가 있던
|
||||||
|
"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고,
|
||||||
|
실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다.
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
DB 가 기준이다. 데이터셋 JSON 은 생성 원본일 뿐 적재분과 완전히 같지 않다
|
||||||
|
(지역 간 중복 태그가 한 행으로 합쳐지므로 7,129 → 6,243).
|
||||||
|
|
||||||
|
| 산출물 | 명령 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data/배포용_키워드_DB덤프.xlsx` | `npm run db:export-xlsx` | **DB 7개 테이블 전부**. 8시트 |
|
||||||
|
| `data/ontology-dump.sql.gz` | `npm run db:dump` | **임베딩 포함 그대로 복원**. 13MB, git 제외 |
|
||||||
|
|
||||||
|
### A. pg_dump 복원 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:dump
|
||||||
|
gunzip -c data/ontology-dump.sql.gz | psql "$TARGET_DATABASE_URL"
|
||||||
|
```
|
||||||
|
|
||||||
|
대상 DB 에 `vector` · `ltree` · `pg_trgm` 확장이 있어야 한다. 재임베딩이 없어 즉시 뜬다.
|
||||||
|
복원 검증 완료 — 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존, HNSW 인덱스 재생성, 벡터 검색 동작.
|
||||||
|
|
||||||
|
### B. 재적재
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate && npm run db:seed
|
||||||
|
npm run dataset:ingest && npm run dataset:ingest-nationwide
|
||||||
|
```
|
||||||
|
|
||||||
|
텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.
|
||||||
|
같은 모델이면 값이 동일하게 나오므로 A 와 결과가 같다.
|
||||||
|
|
||||||
|
### 엑셀 시트 (DB 테이블과 1:1)
|
||||||
|
|
||||||
|
| 시트 | 테이블 | 행 |
|
||||||
|
|---|---|---:|
|
||||||
|
| 키워드 | `keyword` | 7,093 |
|
||||||
|
| 지역 | `region` | 70 |
|
||||||
|
| 업종 | `industry` | 9 |
|
||||||
|
| 업체 | `merchant` | 4 |
|
||||||
|
| 업체키워드 | `merchant_keyword` | 11 |
|
||||||
|
| QA(AEO) | `qa_pair` | 20 |
|
||||||
|
| 생성이력 | `generation_run` | 4 |
|
||||||
|
| 배포가이드 | — | 25 |
|
||||||
|
|
||||||
|
임베딩만 담지 않는다 (384 float × 7천 행). `[임베딩]` 열에 보유 여부만 표시하며,
|
||||||
|
같은 모델로 재생성하면 동일하게 복원된다.
|
||||||
|
|
||||||
|
### 현재 적재 내용
|
||||||
|
|
||||||
|
| 출처 | 건수 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| `nationwide` | 6,243 | 전국 54개 지역 |
|
||||||
|
| `dataset` | 850 | 군산 상세 (매칭 엔진 개발용) |
|
||||||
|
| **합계** | **7,093** | 전부 임베딩 보유 |
|
||||||
|
|
||||||
|
⚠ 검색량은 아직 비어 있다. 실서비스 전에 키워드도구로 채우고 월 10 미만을 걷어내야 한다.
|
||||||
|
|
||||||
|
## 전국 지역별 데이터셋 (기획 변경분)
|
||||||
|
|
||||||
|
`data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**.
|
||||||
|
`npm run dataset:nationwide` 로 재생성한다 (`data/regions.json` → JSON → 엑셀).
|
||||||
|
|
||||||
|
시트 4개: `키워드` / `지역마스터` / `지역별요약` / `사용가이드`
|
||||||
|
|
||||||
|
**조합 폭발을 하지 않았다.** 군산 단일 지역 974건을 54개에 곱하면 5만 건이 되는데,
|
||||||
|
단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다. 지역당 ~110건으로 눌렀다.
|
||||||
|
|
||||||
|
**지역 성격이 시설 키워드를 결정한다.** `regions.json` 의 `type`(해변·산간·호수·강변·도심·섬·계곡)에
|
||||||
|
따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 0건, 태안·거제에는 산뷰가 0건이다.
|
||||||
|
|
||||||
|
**티어** — 주력 568 / 보조 3,816 / 롱테일 1,836 / 태그 918.
|
||||||
|
주력은 페이지당 1개만 쓰는 대표 키워드 후보다.
|
||||||
|
|
||||||
|
⚠ **이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.**
|
||||||
|
엑셀의 `월간검색수`·`경쟁도` 열은 비워 두었다. 네이버 검색광고 키워드도구로 채운 뒤
|
||||||
|
월 10 미만을 걷어내야 실제로 쓸 수 있다.
|
||||||
|
|
||||||
|
## 데이터셋 (군산 단일 지역 · 매칭 엔진용)
|
||||||
|
|
||||||
|
`data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건.
|
||||||
|
실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과
|
||||||
|
숙박 시설 용어를 어휘로 두고, 한국 로컬 숙박 검색에서 실제로 쓰이는 패턴만 전개했다.
|
||||||
|
|
||||||
|
| 카테고리 | 건수 | 예시 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 롱테일 | 374 | 군산 커플 오션뷰 펜션 |
|
||||||
|
| 시설 | 104 | 군산 자쿠지 펜션 |
|
||||||
|
| 권역 | 99 | 선유도 독채펜션 |
|
||||||
|
| 동반자 | 98 | 군산 애견동반 펜션 |
|
||||||
|
| 시즌 | 72 | 군산 여름휴가 펜션 |
|
||||||
|
| 관광지 | 64 | 경암동 철길마을 근처 숙소 |
|
||||||
|
| 태그 | 63 | 오션뷰 · 불멍 · 애견운동장 |
|
||||||
|
| 코어 | 51 | 군산 펜션 추천 |
|
||||||
|
| 질문형 | 33 | 군산 펜션 바베큐 가능한가요 |
|
||||||
|
| 의도 | 13 | 군산 펜션 실시간예약 |
|
||||||
|
|
||||||
|
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
|
||||||
|
|
||||||
|
`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다.
|
||||||
|
적재는 upsert 이고, **데이터셋에서 빠진 행은 같이 지운다** — 안 그러면 재빌드할 때마다
|
||||||
|
이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다).
|
||||||
|
|
||||||
|
| 명령 | 용도 |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dataset:build` | 데이터셋 생성 |
|
||||||
|
| `npm run dataset:ingest` | 임베딩 + 적재 + 잔여 정리 |
|
||||||
|
| `npm run dataset:purge` | 큐레이션 외 출처(`llm` 등) 제거. `--apply` 로 실행 |
|
||||||
|
| `npm run dataset:import-related` | 검색광고 키워드도구 내려받기(CSV/JSON) 병합. `--apply` 로 실행 |
|
||||||
|
|
||||||
|
`dataset:import-related` 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는
|
||||||
|
계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아
|
||||||
|
`data/related-keywords.sample.csv` 형식으로 두면 그대로 병합된다 —
|
||||||
|
나중에 API 를 붙여도 이 임포터를 재사용한다.
|
||||||
|
|
||||||
|
## 임베딩 임계값 — 실측으로 정정한 부분
|
||||||
|
|
||||||
|
설계 초안의 코사인 자동 병합 임계값 0.92 는 **틀렸다.** 짧은 한글 키워드에서는
|
||||||
|
같은 도메인이기만 하면 절대 코사인이 기본적으로 높게 나온다.
|
||||||
|
|
||||||
|
| 쌍 | 실제 관계 | cos (e5-small) |
|
||||||
|
|---|---|---:|
|
||||||
|
| 군산 키즈룸 펜션 ↔ 군산 펜션 키즈룸 | 중복 (어순) | **0.9995** |
|
||||||
|
| 군산 애견동반 펜션 ↔ 군산 반려견 동반 펜션 | 중복 (동의어) | 0.9886 |
|
||||||
|
| 선유도 펜션 ↔ 선유도 팬션 | 중복 (오타) | 0.9585 |
|
||||||
|
| 군산 펜션 ↔ 군산 호텔 | **별개** | 0.9698 |
|
||||||
|
| 선유도 펜션 ↔ 새만금 펜션 | **별개** | 0.9356 |
|
||||||
|
|
||||||
|
중복과 별개의 분포가 겹치므로 단일 임계값으로는 깨끗하게 못 가른다
|
||||||
|
(`paraphrase-multilingual-MiniLM-L12-v2` 도 동일).
|
||||||
|
|
||||||
|
그래서 정책을 이렇게 바꿨다.
|
||||||
|
|
||||||
|
- **자동 병합의 주력은 어휘 단계(1~2)** — 공백/구두점 정규화와 `pg_trgm` 이 오타·표기 변형을 잡는다
|
||||||
|
- **벡터 단계는 0.99 로 올려 잡는다** — 어순 변형처럼 확실한 것만 걸린다
|
||||||
|
- **적재 시에는 벡터 병합을 아예 하지 않고 검토 목록만 출력한다** (`npm run dataset:ingest` 끝부분)
|
||||||
|
|
||||||
|
## 중복제거 4단계
|
||||||
|
|
||||||
|
값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다.
|
||||||
|
|
||||||
|
| 단계 | 방법 | 걸러내는 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 금칙어 필터 | `최고`, `1위`, `100%` 등 과장광고 |
|
||||||
|
| 1 | `normalized` 완전 일치 (공백·구두점 제거) | `강남 뿌리 염색` = `강남 뿌리염색` |
|
||||||
|
| 2 | `pg_trgm` 유사도 ≥ 0.6 | `강남 뿌리염색약` → `강남 뿌리염색` |
|
||||||
|
| 3 | 코사인 유사도 ≥ 0.92 | `강남 미용실` ↔ `강남 헤어샵` (의미 중복) |
|
||||||
|
| 4 | 신규 등록 | 위에 안 걸리면 새 키워드 |
|
||||||
|
|
||||||
|
1~3 단계에서 매칭되면 원래 표기는 버리지 않고 기존 키워드의 `aliases[]` 로 흡수한다
|
||||||
|
(롱테일 검색어 보존 + 성과 피드백 매칭에 사용).
|
||||||
|
|
||||||
|
임계값은 `.env` 의 `DEDUP_COSINE_THRESHOLD` / `DEDUP_TRIGRAM_THRESHOLD` 로 조정.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/health` | 헬스체크 |
|
||||||
|
| `POST` | `/v1/merchants/publish` | **사이트 발행 웹훅** — 업체 upsert + 생성 예약 (`sync:true` 면 동기 실행) |
|
||||||
|
| `POST` | `/v1/merchants/:id/generate?sync=true` | 수동 재생성 |
|
||||||
|
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
|
||||||
|
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
|
||||||
|
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
|
||||||
|
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) |
|
||||||
|
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
|
||||||
|
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
|
||||||
|
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
|
||||||
|
|
||||||
|
`:id` 는 `external_id` 또는 내부 UUID 둘 다 받는다.
|
||||||
|
|
||||||
|
### 발행 웹훅 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/v1/merchants/publish \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"externalId": "site-1003",
|
||||||
|
"name": "강남 뷰티랩",
|
||||||
|
"industryId": "beauty.hair",
|
||||||
|
"regionId": "kr.seoul.gangnam",
|
||||||
|
"description": "강남 미용실. 염색 전문.",
|
||||||
|
"profile": { "services": ["뿌리염색", "여성펌"], "features": ["주차가능"] }
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서빙 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "레브살롱 | 강남 미용실",
|
||||||
|
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. ... 정보를 확인하세요.",
|
||||||
|
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "..."],
|
||||||
|
"tags": [{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95, "aliases": ["강남미용실"] }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 매칭 — 속성별 다중 질의 + 사실 기반 필터
|
||||||
|
|
||||||
|
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측:
|
||||||
|
|
||||||
|
| 방식 | 점수 범위 | 폭 |
|
||||||
|
|---|---|---|
|
||||||
|
| 통짜 질의문 하나 | 0.8761 ~ 0.8837 | 0.0076 |
|
||||||
|
| 속성별로 쪼갠 질의 | 0.8552 ~ 0.9176 | **0.0624** |
|
||||||
|
|
||||||
|
976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다.
|
||||||
|
그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다.
|
||||||
|
|
||||||
|
| 레인 | 가중치 | 질의문 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| 유형 | 1.0 | `군산 펜션 독채 감성숙소` |
|
||||||
|
| 위치 | 0.7 | `원도심 신흥동 말랭이마을 동국사 근처` |
|
||||||
|
| 동반자 | 0.6 | `커플 친구 가족 혼자` |
|
||||||
|
| 시설 | 0.6 | `프라이빗` |
|
||||||
|
|
||||||
|
레인 설계에서 실측으로 배운 것 세 가지.
|
||||||
|
|
||||||
|
- **브랜드 레인을 두면 안 된다.** 상호는 사전에 없으므로 결국 `군산 펜션` 만 남아
|
||||||
|
가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 `~예약` 으로 도배됐다.
|
||||||
|
- **레인끼리 겹치면 안 된다.** 권역과 인근을 따로 두었더니 `신흥동` 토큰이 양쪽에 걸려
|
||||||
|
위치 키워드가 상위를 쓸어갔고, 정작 핵심인 `군산 펜션 독채` 가 8위로 밀렸다. 한 레인으로 합쳤다.
|
||||||
|
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
||||||
|
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
|
||||||
|
|
||||||
|
#### 레인 구성
|
||||||
|
|
||||||
|
| 레인 | 가중치 | 출처 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 유형 | 1.0 | 지역 + 업종 + 숙소유형 | 앵커. 주력 키워드가 여기서 나온다 |
|
||||||
|
| **고객언어** | **0.9** | `reviewSignals` 빈출어 + `hashtags` | 사업자 표현보다 검색어에 가깝다 |
|
||||||
|
| 위치 | 0.7 | 권역 + 행정동 + 인근 랜드마크 | |
|
||||||
|
| 동반자 | 0.6 | `audiences` | |
|
||||||
|
| 시설 | 0.6 | 정규화된 `amenities` | |
|
||||||
|
|
||||||
|
레인 텍스트는 **낱말 단위로 중복을 제거**한다. 문자열 단위 Set 만으로는 `신흥동` 과
|
||||||
|
`신흥동 일본식가옥` 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다.
|
||||||
|
|
||||||
|
후보 풀은 **업체 업종으로 한정**하고 `source IN ('dataset','manual')` 만 본다.
|
||||||
|
사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다.
|
||||||
|
|
||||||
|
#### 고객 언어 신호
|
||||||
|
|
||||||
|
리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"hashtags": ["#군산감성숙소", "#뚜벅이여행"],
|
||||||
|
"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }]
|
||||||
|
```
|
||||||
|
|
||||||
|
빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다.
|
||||||
|
현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다.
|
||||||
|
|
||||||
|
#### 사실 기반 필터 — 벡터가 못 거르는 것
|
||||||
|
|
||||||
|
임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.
|
||||||
|
|
||||||
|
| 규칙 | 예시 |
|
||||||
|
|---|---|
|
||||||
|
| 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 |
|
||||||
|
| 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 |
|
||||||
|
| 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 |
|
||||||
|
| **미확인 시설** | `바베큐` 가 `unverified` → 배제하지 않고 **보류** 표시 |
|
||||||
|
|
||||||
|
마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다.
|
||||||
|
스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다.
|
||||||
|
|
||||||
|
#### 레인별 출력 = SEO 페이지 배분
|
||||||
|
|
||||||
|
응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 —
|
||||||
|
한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다.
|
||||||
|
|
||||||
|
| 레인 | → 페이지 | 주력 |
|
||||||
|
|---|---|---|
|
||||||
|
| 유형 | 메인 | 군산 펜션 독채 |
|
||||||
|
| 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 |
|
||||||
|
| 동반자 | 객실 | 군산 커플 프라이빗 펜션 |
|
||||||
|
| 시설 | 시설 | 군산 프라이빗 펜션 |
|
||||||
|
|
||||||
|
### 매칭 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:3100/v1/match \
|
||||||
|
-H 'content-type: application/json' -d '{"query":"스테이 머뭄","limit":5}'
|
||||||
|
```
|
||||||
|
|
||||||
|
업체명이면 상호만으로 임베딩하지 않고 **프로필 전체를 질의문으로 조립**한다.
|
||||||
|
상호는 브랜드명이라 그것만으로는 매칭이 얕아지기 때문이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
해석: 스테이머뭄 (군산 / 펜션) ← "스테이 머뭄" 과 띄어쓰기가 달라도 해석됨
|
||||||
|
질의문: 스테이머뭄 군산 펜션 고군산군도 초입에 자리한 독채 펜션 … 오션뷰 애견동반 …
|
||||||
|
|
||||||
|
0.8765 군산 독채펜션 예약 [transactional] 코어
|
||||||
|
0.8762 군산 애견동반 독채펜션 [local] 동반자
|
||||||
|
0.8751 고군산군도 독채펜션 [local] 권역
|
||||||
|
0.8735 군산 바베큐 펜션 예약 [transactional] 시설
|
||||||
|
0.8718 군산 오션뷰 펜션 예약 [transactional] 시설
|
||||||
|
```
|
||||||
|
|
||||||
|
업체가 해석되지 않으면 입력 문장을 그대로 질의로 쓴다.
|
||||||
|
|
||||||
|
```
|
||||||
|
"선유도 근처에서 바베큐 되는 독채"
|
||||||
|
0.9166 선유도 독채펜션
|
||||||
|
0.9155 선유도 바베큐 펜션
|
||||||
|
0.9051 선유도해수욕장 근처 숙소
|
||||||
|
```
|
||||||
|
|
||||||
|
## 생성 주기
|
||||||
|
|
||||||
|
- **발행 즉시** — `/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창)
|
||||||
|
- **주기 리프레시** — 매일 03:00 크론이 `REFRESH_INTERVAL_DAYS`(기본 30일) 지난 업체를 적재
|
||||||
|
- **성과 기반** — 노출 100회 이상 & CTR < 0.2% 인 키워드는 `demoted` 로 강등, 다음 사이클에서 대체
|
||||||
|
|
||||||
|
프롬프트에는 해당 업체와 같은 업종의 기존 키워드 목록을 넣어 **중복 후보 생성 자체를 줄인다.**
|
||||||
|
그래도 남는 중복만 위 4단계가 처리한다.
|
||||||
|
|
||||||
|
## 남은 작업
|
||||||
|
|
||||||
|
- [ ] JSON-LD (`LocalBusiness` / `FAQPage` / `Service`) 조립 — `structuredDataHints` 를 그대로 매핑
|
||||||
|
- [ ] `/llms.txt` 서빙
|
||||||
|
- [ ] 업종 `ltree` 상위 노드 키워드 상속 (`source: 'inherited'`)
|
||||||
|
- [ ] Redis 응답 캐시 (서빙은 읽기 99%)
|
||||||
|
- [ ] Search Console API 연동 (현재는 `/performance` 수동 주입)
|
||||||
|
- [ ] 어드민 UI
|
||||||
|
|
||||||
|
## 아키텍처 도식
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/architecture.html` | 브라우저용 설계 문서 — 전체 흐름 · 중복제거 단계 · 데이터 모델 |
|
||||||
|
| `docs/architecture.pptx` | 발표용 11장 덱. 도식은 이미지가 아니라 네이티브 도형이라 PowerPoint 에서 바로 편집된다 |
|
||||||
|
|
||||||
|
덱은 `python3 scripts/build-deck.py` 로 다시 생성한다 (`pip install python-pptx` 필요).
|
||||||
|
한글 폰트는 `Apple SD Gothic Neo`, 코드는 `Menlo` 로 지정되어 있다 — Windows 에서 열 때는
|
||||||
|
`scripts/build-deck.py` 상단의 `SANS` / `MONO` 를 `맑은 고딕` / `Consolas` 로 바꿔 다시 생성하면 된다.
|
||||||
7008
ontology/data/gunsan-pension-keywords.json
Normal file
7008
ontology/data/gunsan-pension-keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
85557
ontology/data/nationwide-pension-keywords.json
Normal file
85557
ontology/data/nationwide-pension-keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
803
ontology/data/regions.json
Normal file
803
ontology/data/regions.json
Normal file
@ -0,0 +1,803 @@
|
|||||||
|
{
|
||||||
|
"note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령). ski=true 는 실제 스키장이 있는 지역 (산간이라고 다 스키장이 있는 건 아니다).",
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"sido": "경기",
|
||||||
|
"name": "가평",
|
||||||
|
"key": "kr.gyeonggi.gapyeong",
|
||||||
|
"type": [
|
||||||
|
"호수",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"남이섬",
|
||||||
|
"쁘띠프랑스",
|
||||||
|
"아침고요수목원",
|
||||||
|
"자라섬",
|
||||||
|
"청평호"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경기",
|
||||||
|
"name": "양평",
|
||||||
|
"key": "kr.gyeonggi.yangpyeong",
|
||||||
|
"type": [
|
||||||
|
"강변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"두물머리",
|
||||||
|
"세미원",
|
||||||
|
"용문사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경기",
|
||||||
|
"name": "포천",
|
||||||
|
"key": "kr.gyeonggi.pocheon",
|
||||||
|
"type": [
|
||||||
|
"호수",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"산정호수",
|
||||||
|
"포천아트밸리",
|
||||||
|
"허브아일랜드"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경기",
|
||||||
|
"name": "파주",
|
||||||
|
"key": "kr.gyeonggi.paju",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"헤이리예술마을",
|
||||||
|
"임진각",
|
||||||
|
"프로방스마을"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "인천",
|
||||||
|
"name": "강화",
|
||||||
|
"key": "kr.incheon.ganghwa",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"마니산",
|
||||||
|
"동막해변",
|
||||||
|
"강화고인돌",
|
||||||
|
"전등사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "인천",
|
||||||
|
"name": "을왕리",
|
||||||
|
"key": "kr.incheon.yeongjong",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"을왕리해수욕장",
|
||||||
|
"무의도",
|
||||||
|
"하나개해수욕장"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"영종도"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "춘천",
|
||||||
|
"key": "kr.gangwon.chuncheon",
|
||||||
|
"type": [
|
||||||
|
"호수",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"소양강스카이워크",
|
||||||
|
"김유정역",
|
||||||
|
"의암호",
|
||||||
|
"남이섬"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "홍천",
|
||||||
|
"key": "kr.gangwon.hongcheon",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"비발디파크",
|
||||||
|
"은행나무숲",
|
||||||
|
"홍천강"
|
||||||
|
],
|
||||||
|
"ski": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "인제",
|
||||||
|
"key": "kr.gangwon.inje",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"자작나무숲",
|
||||||
|
"내린천",
|
||||||
|
"백담사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "평창",
|
||||||
|
"key": "kr.gangwon.pyeongchang",
|
||||||
|
"type": [
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"대관령",
|
||||||
|
"오대산",
|
||||||
|
"월정사",
|
||||||
|
"알펜시아",
|
||||||
|
"양떼목장"
|
||||||
|
],
|
||||||
|
"ski": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "정선",
|
||||||
|
"key": "kr.gangwon.jeongseon",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"하이원리조트",
|
||||||
|
"레일바이크",
|
||||||
|
"병방치스카이워크"
|
||||||
|
],
|
||||||
|
"ski": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "강릉",
|
||||||
|
"key": "kr.gangwon.gangneung",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"경포대",
|
||||||
|
"안목해변",
|
||||||
|
"정동진",
|
||||||
|
"주문진",
|
||||||
|
"오죽헌"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "속초",
|
||||||
|
"key": "kr.gangwon.sokcho",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"설악산",
|
||||||
|
"대포항",
|
||||||
|
"영금정",
|
||||||
|
"아바이마을",
|
||||||
|
"속초해수욕장"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "양양",
|
||||||
|
"key": "kr.gangwon.yangyang",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"낙산사",
|
||||||
|
"죽도해변",
|
||||||
|
"하조대",
|
||||||
|
"서피비치",
|
||||||
|
"인구해변"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "고성",
|
||||||
|
"key": "kr.gangwon.goseong",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"송지호",
|
||||||
|
"화진포",
|
||||||
|
"통일전망대",
|
||||||
|
"백섬해상전망대"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "동해",
|
||||||
|
"key": "kr.gangwon.donghae",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"추암촛대바위",
|
||||||
|
"망상해수욕장",
|
||||||
|
"무릉계곡"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "삼척",
|
||||||
|
"key": "kr.gangwon.samcheok",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"장호항",
|
||||||
|
"환선굴",
|
||||||
|
"맹방해변"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "강원",
|
||||||
|
"name": "태백",
|
||||||
|
"key": "kr.gangwon.taebaek",
|
||||||
|
"type": [
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"태백산",
|
||||||
|
"검룡소",
|
||||||
|
"365세이프타운"
|
||||||
|
],
|
||||||
|
"ski": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충북",
|
||||||
|
"name": "단양",
|
||||||
|
"key": "kr.chungbuk.danyang",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"도담삼봉",
|
||||||
|
"만천하스카이워크",
|
||||||
|
"고수동굴",
|
||||||
|
"단양강잔도"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충북",
|
||||||
|
"name": "제천",
|
||||||
|
"key": "kr.chungbuk.jecheon",
|
||||||
|
"type": [
|
||||||
|
"호수",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"청풍호",
|
||||||
|
"의림지",
|
||||||
|
"배론성지"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충북",
|
||||||
|
"name": "충주",
|
||||||
|
"key": "kr.chungbuk.chungju",
|
||||||
|
"type": [
|
||||||
|
"호수",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"탄금대",
|
||||||
|
"수안보온천",
|
||||||
|
"충주호"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충북",
|
||||||
|
"name": "괴산",
|
||||||
|
"key": "kr.chungbuk.goesan",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"산막이옛길",
|
||||||
|
"화양구곡"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충남",
|
||||||
|
"name": "태안",
|
||||||
|
"key": "kr.chungnam.taean",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"만리포해수욕장",
|
||||||
|
"꽃지해변",
|
||||||
|
"안면도",
|
||||||
|
"신두리해안사구",
|
||||||
|
"청포대"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충남",
|
||||||
|
"name": "대천",
|
||||||
|
"key": "kr.chungnam.boryeong",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"대천해수욕장",
|
||||||
|
"무창포",
|
||||||
|
"죽도상화원",
|
||||||
|
"성주산"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"보령"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충남",
|
||||||
|
"name": "서산",
|
||||||
|
"key": "kr.chungnam.seosan",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"해미읍성",
|
||||||
|
"간월암",
|
||||||
|
"개심사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충남",
|
||||||
|
"name": "공주",
|
||||||
|
"key": "kr.chungnam.gongju",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"공산성",
|
||||||
|
"무령왕릉",
|
||||||
|
"마곡사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "충남",
|
||||||
|
"name": "부여",
|
||||||
|
"key": "kr.chungnam.buyeo",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"궁남지",
|
||||||
|
"부소산성",
|
||||||
|
"백제문화단지"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전북",
|
||||||
|
"name": "군산",
|
||||||
|
"key": "kr.jeonbuk.gunsan",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"선유도",
|
||||||
|
"고군산군도",
|
||||||
|
"말랭이마을",
|
||||||
|
"경암동 철길마을",
|
||||||
|
"근대역사박물관",
|
||||||
|
"은파호수공원",
|
||||||
|
"새만금"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전북",
|
||||||
|
"name": "변산",
|
||||||
|
"key": "kr.jeonbuk.buan",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"채석강",
|
||||||
|
"변산해수욕장",
|
||||||
|
"내소사",
|
||||||
|
"격포항"
|
||||||
|
],
|
||||||
|
"aliases": [
|
||||||
|
"부안"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전북",
|
||||||
|
"name": "전주",
|
||||||
|
"key": "kr.jeonbuk.jeonju",
|
||||||
|
"type": [
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"전주한옥마을",
|
||||||
|
"경기전",
|
||||||
|
"전동성당",
|
||||||
|
"남부시장"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전북",
|
||||||
|
"name": "무주",
|
||||||
|
"key": "kr.jeonbuk.muju",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"덕유산",
|
||||||
|
"무주리조트",
|
||||||
|
"반디랜드",
|
||||||
|
"구천동계곡"
|
||||||
|
],
|
||||||
|
"ski": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전북",
|
||||||
|
"name": "남원",
|
||||||
|
"key": "kr.jeonbuk.namwon",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"광한루원",
|
||||||
|
"지리산",
|
||||||
|
"춘향테마파크"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "여수",
|
||||||
|
"key": "kr.jeonnam.yeosu",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"오동도",
|
||||||
|
"돌산대교",
|
||||||
|
"향일암",
|
||||||
|
"여수해상케이블카",
|
||||||
|
"낭만포차"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "순천",
|
||||||
|
"key": "kr.jeonnam.suncheon",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"습지"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"순천만습지",
|
||||||
|
"낙안읍성",
|
||||||
|
"순천만국가정원"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "담양",
|
||||||
|
"key": "kr.jeonnam.damyang",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"죽녹원",
|
||||||
|
"메타세쿼이아길",
|
||||||
|
"소쇄원"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "구례",
|
||||||
|
"key": "kr.jeonnam.gurye",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"지리산",
|
||||||
|
"화엄사",
|
||||||
|
"섬진강",
|
||||||
|
"사성암"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "해남",
|
||||||
|
"key": "kr.jeonnam.haenam",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"땅끝마을",
|
||||||
|
"두륜산",
|
||||||
|
"대흥사"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "완도",
|
||||||
|
"key": "kr.jeonnam.wando",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"섬"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"청산도",
|
||||||
|
"명사십리해수욕장",
|
||||||
|
"완도타워"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "전남",
|
||||||
|
"name": "보성",
|
||||||
|
"key": "kr.jeonnam.boseong",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"보성녹차밭",
|
||||||
|
"율포해수욕장"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "경주",
|
||||||
|
"key": "kr.gyeongbuk.gyeongju",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"호수"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"불국사",
|
||||||
|
"첨성대",
|
||||||
|
"황리단길",
|
||||||
|
"보문단지",
|
||||||
|
"동궁과월지"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "포항",
|
||||||
|
"key": "kr.gyeongbuk.pohang",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"호미곶",
|
||||||
|
"영일대해수욕장",
|
||||||
|
"죽도시장",
|
||||||
|
"스페이스워크"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "안동",
|
||||||
|
"key": "kr.gyeongbuk.andong",
|
||||||
|
"type": [
|
||||||
|
"도심",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"하회마을",
|
||||||
|
"월영교",
|
||||||
|
"도산서원"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "영덕",
|
||||||
|
"key": "kr.gyeongbuk.yeongdeok",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"강구항",
|
||||||
|
"블루로드",
|
||||||
|
"고래불해수욕장"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "울진",
|
||||||
|
"key": "kr.gyeongbuk.uljin",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"죽변항",
|
||||||
|
"덕구온천",
|
||||||
|
"성류굴",
|
||||||
|
"후포항"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경북",
|
||||||
|
"name": "문경",
|
||||||
|
"key": "kr.gyeongbuk.mungyeong",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"문경새재",
|
||||||
|
"단산모노레일",
|
||||||
|
"에코랄라"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "거제",
|
||||||
|
"key": "kr.gyeongnam.geoje",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"섬"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"외도보타니아",
|
||||||
|
"바람의언덕",
|
||||||
|
"학동몽돌해변",
|
||||||
|
"매미성",
|
||||||
|
"windy hill"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "통영",
|
||||||
|
"key": "kr.gyeongnam.tongyeong",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"섬"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"동피랑벽화마을",
|
||||||
|
"통영케이블카",
|
||||||
|
"미륵산",
|
||||||
|
"한산도",
|
||||||
|
"장사도"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "남해",
|
||||||
|
"key": "kr.gyeongnam.namhae",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"섬"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"다랭이마을",
|
||||||
|
"독일마을",
|
||||||
|
"상주은모래비치",
|
||||||
|
"보리암"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "하동",
|
||||||
|
"key": "kr.gyeongnam.hadong",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"강변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"화개장터",
|
||||||
|
"쌍계사",
|
||||||
|
"섬진강",
|
||||||
|
"최참판댁"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "사천",
|
||||||
|
"key": "kr.gyeongnam.sacheon",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"삼천포대교",
|
||||||
|
"사천케이블카",
|
||||||
|
"실안노을길"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "경남",
|
||||||
|
"name": "산청",
|
||||||
|
"key": "kr.gyeongnam.sancheong",
|
||||||
|
"type": [
|
||||||
|
"산간",
|
||||||
|
"계곡"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"지리산",
|
||||||
|
"동의보감촌",
|
||||||
|
"대원사계곡"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "부산",
|
||||||
|
"name": "기장",
|
||||||
|
"key": "kr.busan.gijang",
|
||||||
|
"type": [
|
||||||
|
"해변"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"해동용궁사",
|
||||||
|
"일광해수욕장",
|
||||||
|
"아난티코브",
|
||||||
|
"죽성성당"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "제주",
|
||||||
|
"name": "제주시",
|
||||||
|
"key": "kr.jeju.jejusi",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"도심"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"애월",
|
||||||
|
"함덕해수욕장",
|
||||||
|
"협재해수욕장",
|
||||||
|
"이호테우",
|
||||||
|
"한림공원"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sido": "제주",
|
||||||
|
"name": "서귀포",
|
||||||
|
"key": "kr.jeju.seogwipo",
|
||||||
|
"type": [
|
||||||
|
"해변",
|
||||||
|
"산간"
|
||||||
|
],
|
||||||
|
"spots": [
|
||||||
|
"중문색달해변",
|
||||||
|
"성산일출봉",
|
||||||
|
"쇠소깍",
|
||||||
|
"천지연폭포",
|
||||||
|
"우도"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
ontology/data/related-keywords.sample.csv
Normal file
8
ontology/data/related-keywords.sample.csv
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
|
||||||
|
# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
|
||||||
|
relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
|
||||||
|
군산독채펜션,210,1830,중간
|
||||||
|
군산감성숙소,90,760,낮음
|
||||||
|
군산2인펜션,40,310,낮음
|
||||||
|
군산뚜벅이여행,30,240,낮음
|
||||||
|
말랭이마을숙소,10,90,낮음
|
||||||
|
BIN
ontology/data/배포용_키워드_DB덤프.xlsx
Normal file
BIN
ontology/data/배포용_키워드_DB덤프.xlsx
Normal file
Binary file not shown.
BIN
ontology/data/전국_펜션_SEO_AEO_키워드.xlsx
Normal file
BIN
ontology/data/전국_펜션_SEO_AEO_키워드.xlsx
Normal file
Binary file not shown.
33
ontology/docker-compose.yml
Normal file
33
ontology/docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: ontology-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ontology
|
||||||
|
POSTGRES_PASSWORD: ontology
|
||||||
|
POSTGRES_DB: ontology
|
||||||
|
ports:
|
||||||
|
- '55432:5432'
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: ontology-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '56379:6379'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
714
ontology/docs/architecture.html
Normal file
714
ontology/docs/architecture.html
Normal file
@ -0,0 +1,714 @@
|
|||||||
|
<title>키워드 온톨로지 설계</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Gowun+Batang:wght@400;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&display=swap">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eceff0;
|
||||||
|
--ink: #101819;
|
||||||
|
--ink-soft: #3d4c4e;
|
||||||
|
--muted: #63757a;
|
||||||
|
--line: #d5dcdb;
|
||||||
|
--line-soft: #e4e9e8;
|
||||||
|
--accent: #0d6a60;
|
||||||
|
--accent-bg: #dff0ec;
|
||||||
|
--warn: #8a5a06;
|
||||||
|
--warn-bg: #f6ead2;
|
||||||
|
--stop: #9d3a30;
|
||||||
|
--stop-bg: #f6e0dc;
|
||||||
|
--shadow: 0 1px 2px rgba(16,24,25,.05), 0 8px 24px -16px rgba(16,24,25,.35);
|
||||||
|
|
||||||
|
--display: 'Gowun Batang', 'Apple SD Gothic Neo', serif;
|
||||||
|
--body: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', -apple-system, sans-serif;
|
||||||
|
--mono: 'IBM Plex Mono', 'SFMono-Regular', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #0d1213;
|
||||||
|
--surface: #141b1c;
|
||||||
|
--surface-2: #1b2425;
|
||||||
|
--ink: #e7edeb;
|
||||||
|
--ink-soft: #c2cecd;
|
||||||
|
--muted: #8d9d9f;
|
||||||
|
--line: #263130;
|
||||||
|
--line-soft: #1e2728;
|
||||||
|
--accent: #56c2b1;
|
||||||
|
--accent-bg: #12312e;
|
||||||
|
--warn: #d7a34a;
|
||||||
|
--warn-bg: #33270f;
|
||||||
|
--stop: #e28a80;
|
||||||
|
--stop-bg: #37201d;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0d1213;
|
||||||
|
--surface: #141b1c;
|
||||||
|
--surface-2: #1b2425;
|
||||||
|
--ink: #e7edeb;
|
||||||
|
--ink-soft: #c2cecd;
|
||||||
|
--muted: #8d9d9f;
|
||||||
|
--line: #263130;
|
||||||
|
--line-soft: #1e2728;
|
||||||
|
--accent: #56c2b1;
|
||||||
|
--accent-bg: #12312e;
|
||||||
|
--warn: #d7a34a;
|
||||||
|
--warn-bg: #33270f;
|
||||||
|
--stop: #e28a80;
|
||||||
|
--stop-bg: #37201d;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--body);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1240px; margin: 0 auto; padding: 56px 32px 96px; }
|
||||||
|
.col { max-width: 760px; }
|
||||||
|
|
||||||
|
/* ---------- masthead ---------- */
|
||||||
|
.masthead { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 44px; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
||||||
|
letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--display); font-weight: 700;
|
||||||
|
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.18; letter-spacing: -.01em;
|
||||||
|
margin: 0 0 16px; text-wrap: balance;
|
||||||
|
}
|
||||||
|
.standfirst { font-size: 17px; color: var(--ink-soft); margin: 0; max-width: 62ch; font-weight: 300; }
|
||||||
|
.meta {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px;
|
||||||
|
font-family: var(--mono); font-size: 11.5px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.meta span {
|
||||||
|
border: 1px solid var(--line); border-radius: 3px;
|
||||||
|
padding: 3px 9px; background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sections ---------- */
|
||||||
|
section { margin-top: 64px; }
|
||||||
|
h2 {
|
||||||
|
font-family: var(--display); font-weight: 700;
|
||||||
|
font-size: 25px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -.005em;
|
||||||
|
}
|
||||||
|
.lede { color: var(--muted); margin: 0 0 26px; max-width: 66ch; font-size: 15px; }
|
||||||
|
h3 {
|
||||||
|
font-size: 15px; font-weight: 600; margin: 34px 0 10px;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
p { margin: 0 0 14px; max-width: 68ch; }
|
||||||
|
strong { font-weight: 600; }
|
||||||
|
code {
|
||||||
|
font-family: var(--mono); font-size: .875em;
|
||||||
|
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- figures ---------- */
|
||||||
|
figure { margin: 0 0 8px; }
|
||||||
|
.fig {
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 6px; box-shadow: var(--shadow);
|
||||||
|
padding: 26px 22px 18px; margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.fig-scroll { overflow-x: auto; }
|
||||||
|
.fig svg { display: block; min-width: 720px; max-width: 100%; height: auto; color: var(--ink); }
|
||||||
|
figcaption {
|
||||||
|
font-size: 13px; color: var(--muted); margin-top: 16px;
|
||||||
|
padding-top: 14px; border-top: 1px solid var(--line-soft); max-width: 78ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tables ---------- */
|
||||||
|
.tbl-wrap { overflow-x: auto; margin: 20px 0 8px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 14px; min-width: 520px; }
|
||||||
|
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
|
||||||
|
thead th {
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
td.mono, th.mono { font-family: var(--mono); font-size: 12.5px; }
|
||||||
|
.num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------- callout ---------- */
|
||||||
|
.verdict {
|
||||||
|
background: var(--accent-bg); border-left: 3px solid var(--accent);
|
||||||
|
padding: 18px 22px; border-radius: 0 5px 5px 0; margin: 24px 0;
|
||||||
|
}
|
||||||
|
.verdict p { margin: 0; max-width: none; }
|
||||||
|
.verdict p + p { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ---------- stage list (진짜 순서가 있는 것에만) ---------- */
|
||||||
|
ol.stages { list-style: none; counter-reset: s -1; padding: 0; margin: 20px 0 8px; }
|
||||||
|
ol.stages li {
|
||||||
|
counter-increment: s; display: grid;
|
||||||
|
grid-template-columns: 34px 1fr; gap: 16px;
|
||||||
|
padding: 14px 0; border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
ol.stages li:last-child { border-bottom: none; }
|
||||||
|
ol.stages li::before {
|
||||||
|
content: counter(s);
|
||||||
|
font-family: var(--mono); font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--accent); border: 1px solid var(--line);
|
||||||
|
border-radius: 3px; height: 26px; display: grid; place-items: center;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
ol.stages b { display: block; font-weight: 600; font-size: 14.5px; }
|
||||||
|
ol.stages span { font-size: 13.5px; color: var(--muted); }
|
||||||
|
|
||||||
|
ul.plain { padding-left: 20px; margin: 12px 0; }
|
||||||
|
ul.plain li { margin-bottom: 7px; max-width: 68ch; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 5px;
|
||||||
|
padding: 16px 18px; overflow-x: auto; font-family: var(--mono);
|
||||||
|
font-size: 12.5px; line-height: 1.75; margin: 16px 0; color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
pre b { color: var(--accent); font-weight: 500; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 11px;
|
||||||
|
padding: 2px 7px; border-radius: 3px; letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.pill-go { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.pill-warn { background: var(--warn-bg); color: var(--warn); }
|
||||||
|
.pill-stop { background: var(--stop-bg); color: var(--stop); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 76px; padding-top: 22px; border-top: 1px solid var(--line);
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
}
|
||||||
|
a { color: var(--accent); }
|
||||||
|
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="masthead col">
|
||||||
|
<p class="eyebrow">o2o-site-ontology</p>
|
||||||
|
<h1>발행 사이트에 붙는<br>SEO/AEO 키워드 온톨로지</h1>
|
||||||
|
<p class="standfirst">
|
||||||
|
업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.
|
||||||
|
LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고,
|
||||||
|
발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.
|
||||||
|
</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>PostgreSQL 16 + pgvector</span>
|
||||||
|
<span>NestJS</span>
|
||||||
|
<span>BullMQ</span>
|
||||||
|
<span>OpenAI Structured Outputs</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======================================================= 1 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>일반 DB 냐 벡터 DB 냐</h2>
|
||||||
|
<p class="lede">둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tbl-wrap col">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>조회 유형</th><th>실제 질의</th><th>필요한 것</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>정확 조회</td>
|
||||||
|
<td>업체 A 의 활성 키워드 20개</td>
|
||||||
|
<td class="mono">B-tree / 관계형 조인</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>의미 조회</td>
|
||||||
|
<td>이 후보가 기존 키워드와 의미상 겹치는가</td>
|
||||||
|
<td class="mono">vector (HNSW)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>관계 탐색</td>
|
||||||
|
<td>업종 트리 상위에서 물려받을 공통 키워드</td>
|
||||||
|
<td class="mono">ltree 계층 / recursive CTE</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verdict col">
|
||||||
|
<p><strong>결론 — PostgreSQL 하나로 시작한다.</strong>
|
||||||
|
<code>pgvector</code> + <code>ltree</code> + <code>pg_trgm</code> + <code>JSONB</code> 로 세 가지가 모두 한 엔진 안에서 해결되고,
|
||||||
|
무엇보다 <em>키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.</em></p>
|
||||||
|
<p>전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다.
|
||||||
|
벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.
|
||||||
|
Neo4j 도 같은 논리 — 고정 깊이 상속이면 <code>ltree</code> 로 충분하다.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 2 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>전체 흐름</h2>
|
||||||
|
<p class="lede">생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<div class="fig fig-scroll">
|
||||||
|
<svg viewBox="0 0 1160 500" role="img"
|
||||||
|
aria-label="트리거가 BullMQ 큐에 적재되고, 생성 워커가 OpenAI 를 호출해 후보 키워드를 만들고, 4단계 중복제거를 거쳐 PostgreSQL 에 저장되며, 서빙 API 가 발행 사이트에 SEO/AEO payload 를 내려주고, 유입 성과가 다시 트리거로 돌아오는 순환 구조">
|
||||||
|
<defs>
|
||||||
|
<marker id="a1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="a1acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- boxes -->
|
||||||
|
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)" opacity="1">
|
||||||
|
<rect x="24" y="64" width="180" height="88" rx="4"/>
|
||||||
|
<rect x="252" y="64" width="180" height="88" rx="4"/>
|
||||||
|
<rect x="480" y="64" width="180" height="88" rx="4"/>
|
||||||
|
<rect x="708" y="248" width="180" height="88" rx="4"/>
|
||||||
|
<rect x="252" y="248" width="180" height="88" rx="4"/>
|
||||||
|
<rect x="252" y="400" width="400" height="60" rx="4"/>
|
||||||
|
</g>
|
||||||
|
<rect x="708" y="64" width="180" height="88" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.5"/>
|
||||||
|
<rect x="936" y="48" width="200" height="120" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- labels -->
|
||||||
|
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
|
||||||
|
<text x="40" y="88" font-size="13" font-weight="600">트리거</text>
|
||||||
|
<text x="40" y="110" font-size="11" opacity=".75">사이트 발행 — 즉시</text>
|
||||||
|
<text x="40" y="127" font-size="11" opacity=".75">크론 03:00 — 30일 경과</text>
|
||||||
|
<text x="40" y="144" font-size="11" opacity=".75">성과 저조 — 재생성</text>
|
||||||
|
|
||||||
|
<text x="268" y="88" font-size="13" font-weight="600">BullMQ 큐</text>
|
||||||
|
<text x="268" y="110" font-size="11" opacity=".75">60초 dedupe 창</text>
|
||||||
|
<text x="268" y="127" font-size="11" opacity=".75">재시도 3회 · 지수 백오프</text>
|
||||||
|
<text x="268" y="144" font-size="11" opacity=".75">동시성 2</text>
|
||||||
|
|
||||||
|
<text x="496" y="88" font-size="13" font-weight="600">생성 워커</text>
|
||||||
|
<text x="496" y="110" font-size="11" opacity=".75">OpenAI · gpt-4.1-mini</text>
|
||||||
|
<text x="496" y="127" font-size="11" opacity=".75">Structured Outputs</text>
|
||||||
|
<text x="496" y="144" font-size="11" opacity=".75">임베딩 배치 1회</text>
|
||||||
|
|
||||||
|
<text x="724" y="88" font-size="13" font-weight="600" fill="var(--warn)">중복제거 4단계</text>
|
||||||
|
<text x="724" y="110" font-size="11" fill="var(--warn)" opacity=".9">해시 → trigram → 벡터</text>
|
||||||
|
<text x="724" y="127" font-size="11" fill="var(--warn)" opacity=".9">미일치만 신규 등록</text>
|
||||||
|
<text x="724" y="144" font-size="11" fill="var(--warn)" opacity=".9">나머지는 alias 흡수</text>
|
||||||
|
|
||||||
|
<text x="952" y="76" font-size="13" font-weight="600" fill="var(--accent)">PostgreSQL 16</text>
|
||||||
|
<text x="952" y="98" font-size="11" fill="var(--accent)" opacity=".9">pgvector · ltree · pg_trgm</text>
|
||||||
|
<text x="952" y="120" font-size="11" fill="var(--accent)" opacity=".9">keyword (전역 사전)</text>
|
||||||
|
<text x="952" y="137" font-size="11" fill="var(--accent)" opacity=".9">merchant_keyword</text>
|
||||||
|
<text x="952" y="154" font-size="11" fill="var(--accent)" opacity=".9">qa_pair · generation_run</text>
|
||||||
|
|
||||||
|
<text x="724" y="272" font-size="13" font-weight="600">Serving API</text>
|
||||||
|
<text x="724" y="294" font-size="11" opacity=".75">GET /v1/sites/:id/seo</text>
|
||||||
|
<text x="724" y="311" font-size="11" opacity=".75">GET /v1/sites/:id/aeo</text>
|
||||||
|
<text x="724" y="328" font-size="11" opacity=".75">읽기 99% · 캐시 대상</text>
|
||||||
|
|
||||||
|
<text x="268" y="272" font-size="13" font-weight="600">발행된 사이트</text>
|
||||||
|
<text x="268" y="294" font-size="11" opacity=".75">o2o-site-AEO</text>
|
||||||
|
<text x="268" y="311" font-size="11" opacity=".75">렌더링 시 호출</text>
|
||||||
|
|
||||||
|
<text x="268" y="426" font-size="13" font-weight="600">성과 수집</text>
|
||||||
|
<text x="268" y="447" font-size="11" opacity=".75">Search Console · 네이버 서치어드바이저 · 유입 로그</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- flow arrows -->
|
||||||
|
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a1)">
|
||||||
|
<line x1="204" y1="108" x2="244" y2="108"/>
|
||||||
|
<line x1="432" y1="108" x2="472" y2="108"/>
|
||||||
|
<line x1="660" y1="108" x2="700" y2="108"/>
|
||||||
|
<line x1="888" y1="108" x2="928" y2="108"/>
|
||||||
|
<path d="M1036 168 L1036 292 L896 292"/>
|
||||||
|
<line x1="708" y1="292" x2="440" y2="292"/>
|
||||||
|
<line x1="342" y1="336" x2="342" y2="392"/>
|
||||||
|
<path d="M252 430 L114 430 L114 160"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- prompt feedback (dashed, accent) -->
|
||||||
|
<g stroke="var(--accent)" stroke-width="1.4" fill="none" stroke-dasharray="5 4" marker-end="url(#a1acc)">
|
||||||
|
<path d="M1036 48 L1036 24 L570 24 L570 56"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- arrow labels -->
|
||||||
|
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".7">
|
||||||
|
<text x="224" y="100" text-anchor="middle">적재</text>
|
||||||
|
<text x="452" y="100" text-anchor="middle">job</text>
|
||||||
|
<text x="680" y="100" text-anchor="middle">후보</text>
|
||||||
|
<text x="908" y="100" text-anchor="middle">write</text>
|
||||||
|
<text x="1046" y="230">읽기</text>
|
||||||
|
<text x="574" y="284" text-anchor="middle">SEO / AEO payload</text>
|
||||||
|
<text x="352" y="368">노출 · 클릭</text>
|
||||||
|
<text x="124" y="212">CTR < 0.2% → 강등</text>
|
||||||
|
</g>
|
||||||
|
<text x="570" y="16" text-anchor="middle" font-family="IBM Plex Mono, monospace"
|
||||||
|
font-size="10.5" fill="var(--accent)">기존 키워드 주입 — 중복 후보 생성 자체를 억제</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<figcaption>
|
||||||
|
점선 화살표가 이 설계의 핵심이다. 프롬프트에 해당 업종의 기존 키워드를 넣어 중복 후보가 <em>만들어지기 전에</em> 줄이고,
|
||||||
|
그래도 남는 것만 중복제거 단계가 처리한다. 생성 경로(위)와 서빙 경로(아래)는 Postgres 에서만 만나므로
|
||||||
|
OpenAI 가 느리거나 죽어도 발행된 사이트의 응답에는 영향이 없다.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 3 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>중복제거 4단계</h2>
|
||||||
|
<p class="lede">
|
||||||
|
값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<div class="fig fig-scroll">
|
||||||
|
<svg viewBox="0 0 1000 500" role="img"
|
||||||
|
aria-label="LLM 후보 키워드가 금칙어 필터, 정규화 완전 일치, trigram 유사도, 코사인 유사도 순으로 통과하며 각 단계에서 탈락한 것은 차단되거나 기존 키워드의 alias 로 흡수되고, 전부 통과한 것만 새 키워드로 등록된다">
|
||||||
|
<defs>
|
||||||
|
<marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="a2w" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--warn)"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="a2s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--stop)"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="a2acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text x="440" y="26" text-anchor="middle" font-family="IBM Plex Sans KR, sans-serif"
|
||||||
|
font-size="12.5" font-weight="600" fill="currentColor">LLM 후보 키워드</text>
|
||||||
|
<line x1="440" y1="34" x2="440" y2="54" stroke="currentColor" stroke-width="1.4" marker-end="url(#a2)"/>
|
||||||
|
|
||||||
|
<!-- stage spine -->
|
||||||
|
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
|
||||||
|
<rect x="280" y="60" width="320" height="58" rx="4"/>
|
||||||
|
<rect x="280" y="150" width="320" height="58" rx="4"/>
|
||||||
|
<rect x="280" y="240" width="320" height="58" rx="4"/>
|
||||||
|
<rect x="280" y="330" width="320" height="58" rx="4"/>
|
||||||
|
</g>
|
||||||
|
<rect x="280" y="420" width="320" height="58" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
|
||||||
|
<text x="298" y="84" font-size="13" font-weight="600">0 · 금칙어 필터</text>
|
||||||
|
<text x="298" y="104" font-size="11" opacity=".75">최고 · 1위 · 100% · 완치</text>
|
||||||
|
|
||||||
|
<text x="298" y="174" font-size="13" font-weight="600">1 · normalized 완전 일치</text>
|
||||||
|
<text x="298" y="194" font-size="11" opacity=".75">NFKC · 소문자 · 구두점/공백 제거</text>
|
||||||
|
|
||||||
|
<text x="298" y="264" font-size="13" font-weight="600">2 · pg_trgm 유사도 ≥ 0.6</text>
|
||||||
|
<text x="298" y="284" font-size="11" opacity=".75">표기 변형 · 오타</text>
|
||||||
|
|
||||||
|
<text x="298" y="354" font-size="13" font-weight="600">3 · 코사인 유사도 ≥ 0.92</text>
|
||||||
|
<text x="298" y="374" font-size="11" opacity=".75">의미 중복 — 후보 20건 안에서만</text>
|
||||||
|
|
||||||
|
<text x="298" y="444" font-size="13" font-weight="600" fill="var(--accent)">4 · 새 키워드로 INSERT</text>
|
||||||
|
<text x="298" y="464" font-size="11" fill="var(--accent)" opacity=".9">embedding 저장 · usage_count 1</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- pass-down arrows -->
|
||||||
|
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a2)">
|
||||||
|
<line x1="440" y1="118" x2="440" y2="144"/>
|
||||||
|
<line x1="440" y1="208" x2="440" y2="234"/>
|
||||||
|
<line x1="440" y1="298" x2="440" y2="324"/>
|
||||||
|
<line x1="440" y1="388" x2="440" y2="414"/>
|
||||||
|
</g>
|
||||||
|
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".6">
|
||||||
|
<text x="450" y="137">미일치</text>
|
||||||
|
<text x="450" y="227">미일치</text>
|
||||||
|
<text x="450" y="317">미일치</text>
|
||||||
|
<text x="450" y="407">미일치</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- cost annotations (left) -->
|
||||||
|
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".55" text-anchor="end">
|
||||||
|
<text x="262" y="93">비용 0</text>
|
||||||
|
<text x="262" y="183">B-tree 1회</text>
|
||||||
|
<text x="262" y="273">GIN trgm</text>
|
||||||
|
<text x="262" y="363">HNSW top-20</text>
|
||||||
|
<text x="262" y="453">INSERT</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- exits -->
|
||||||
|
<rect x="672" y="66" width="304" height="46" rx="4" fill="var(--stop-bg)" stroke="var(--stop)" stroke-width="1.2"/>
|
||||||
|
<line x1="600" y1="89" x2="664" y2="89" stroke="var(--stop)" stroke-width="1.4" marker-end="url(#a2s)"/>
|
||||||
|
<text x="688" y="84" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600" fill="var(--stop)">차단 — 저장하지 않음</text>
|
||||||
|
<text x="688" y="102" font-family="IBM Plex Mono, monospace" font-size="10.5" fill="var(--stop)" opacity=".9">rejected_banned</text>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<rect x="672" y="156" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
|
||||||
|
<rect x="672" y="246" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
|
||||||
|
<rect x="672" y="336" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="var(--warn)" stroke-width="1.4" marker-end="url(#a2w)">
|
||||||
|
<line x1="600" y1="179" x2="664" y2="179"/>
|
||||||
|
<line x1="600" y1="269" x2="664" y2="269"/>
|
||||||
|
<line x1="600" y1="359" x2="664" y2="359"/>
|
||||||
|
</g>
|
||||||
|
<g font-family="IBM Plex Sans KR, sans-serif" fill="var(--warn)">
|
||||||
|
<text x="688" y="174" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
|
||||||
|
<text x="688" y="192" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리 염색 → 강남 뿌리염색</text>
|
||||||
|
|
||||||
|
<text x="688" y="264" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
|
||||||
|
<text x="688" y="282" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리염색약 → 강남 뿌리염색 (0.67)</text>
|
||||||
|
|
||||||
|
<text x="688" y="354" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
|
||||||
|
<text x="688" y="372" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 헤어샵 → 강남 미용실 (0.94)</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 모든 경로가 합류하는 지점 -->
|
||||||
|
<rect x="672" y="420" width="304" height="52" rx="4"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="5 4" opacity=".8"/>
|
||||||
|
<line x1="824" y1="382" x2="824" y2="414" stroke="var(--warn)" stroke-width="1.4"
|
||||||
|
fill="none" marker-end="url(#a2w)"/>
|
||||||
|
<line x1="600" y1="446" x2="664" y2="446" stroke="var(--accent)" stroke-width="1.4"
|
||||||
|
fill="none" marker-end="url(#a2acc)"/>
|
||||||
|
<text x="688" y="443" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600"
|
||||||
|
fill="currentColor">어느 경로든 업체에는 연결된다</text>
|
||||||
|
<text x="688" y="462" font-family="IBM Plex Mono, monospace" font-size="10.5"
|
||||||
|
fill="currentColor" opacity=".7">merchant_keyword · relevance · status</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<figcaption>
|
||||||
|
1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 <code>aliases[]</code> 에 흡수한다.
|
||||||
|
롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이
|
||||||
|
<code>강남 뿌리염색약</code> 으로 성과를 보고해도 같은 키워드에 매칭된다.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<h3>실제 로컬 실행 결과</h3>
|
||||||
|
<p>같은 지역·업종 업체를 순서대로 발행했을 때 <code>npm run smoke</code> 출력이다.</p>
|
||||||
|
</div>
|
||||||
|
<pre>1. 레브살롱 (첫 업체) 후보 19 → <b>신규 19</b> / 중복 0
|
||||||
|
2. 헤어랩 강남점 후보 19 → <b>신규 4</b> / 중복(정확 15, 표기 0, 의미 0)
|
||||||
|
3. 강남 뷰티랩 후보 16 → <b>신규 3</b> / 중복(정확 12, 표기 1, 의미 0)
|
||||||
|
|
||||||
|
matched_exact 강남 뿌리 염색 (sim=1.000 → '강남 뿌리염색')
|
||||||
|
matched_trigram 강남 뿌리염색약 (sim=0.667 → '강남 뿌리염색')
|
||||||
|
matched_exact 강남미용실추천 (sim=1.000 → '강남 미용실 추천')</pre>
|
||||||
|
<div class="col">
|
||||||
|
<p style="font-size:13.5px;color:var(--muted)">
|
||||||
|
<span class="pill pill-warn">참고</span>
|
||||||
|
위 수치는 <code>LLM_PROVIDER=mock</code> 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다.
|
||||||
|
의미 중복(<code>강남 미용실</code> ↔ <code>강남 헤어샵</code>)은 실제 <code>text-embedding-3-small</code> 로 전환해야 3단계가 발동한다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 4 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>데이터 모델</h2>
|
||||||
|
<p class="lede">
|
||||||
|
키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<div class="fig fig-scroll">
|
||||||
|
<svg viewBox="0 0 1000 420" role="img"
|
||||||
|
aria-label="industry 와 region 계층이 keyword 를 분류하고, merchant 는 merchant_keyword 연결 테이블을 통해 전역 keyword 사전을 참조하며, qa_pair 는 merchant 에 직접 매달린다">
|
||||||
|
<defs>
|
||||||
|
<marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
|
||||||
|
<rect x="24" y="32" width="190" height="62" rx="4"/>
|
||||||
|
<rect x="24" y="116" width="190" height="62" rx="4"/>
|
||||||
|
<rect x="24" y="224" width="190" height="104" rx="4"/>
|
||||||
|
<rect x="380" y="224" width="230" height="104" rx="4"/>
|
||||||
|
<rect x="720" y="250" width="250" height="90" rx="4"/>
|
||||||
|
</g>
|
||||||
|
<rect x="720" y="32" width="250" height="158" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g font-family="IBM Plex Mono, monospace" fill="currentColor">
|
||||||
|
<text x="40" y="56" font-size="12.5" font-weight="600">industry</text>
|
||||||
|
<text x="40" y="76" font-size="10.5" opacity=".7">path ltree · beauty.hair</text>
|
||||||
|
|
||||||
|
<text x="40" y="140" font-size="12.5" font-weight="600">region</text>
|
||||||
|
<text x="40" y="160" font-size="10.5" opacity=".7">path ltree · kr.seoul.gangnam</text>
|
||||||
|
|
||||||
|
<text x="40" y="250" font-size="12.5" font-weight="600">merchant</text>
|
||||||
|
<text x="40" y="270" font-size="10.5" opacity=".7">external_id ← 사이트 ID</text>
|
||||||
|
<text x="40" y="288" font-size="10.5" opacity=".7">description · profile jsonb</text>
|
||||||
|
<text x="40" y="306" font-size="10.5" opacity=".7">last_generated_at</text>
|
||||||
|
|
||||||
|
<text x="396" y="250" font-size="12.5" font-weight="600">merchant_keyword</text>
|
||||||
|
<text x="396" y="270" font-size="10.5" opacity=".7">relevance · status · source</text>
|
||||||
|
<text x="396" y="288" font-size="10.5" opacity=".7">impressions · clicks · ctr</text>
|
||||||
|
<text x="396" y="306" font-size="10.5" opacity=".7">PK (merchant_id, keyword_id)</text>
|
||||||
|
|
||||||
|
<text x="736" y="56" font-size="12.5" font-weight="600" fill="var(--accent)">keyword — 전역 사전</text>
|
||||||
|
<text x="736" y="80" font-size="10.5" fill="var(--accent)" opacity=".9">canonical · 표시용</text>
|
||||||
|
<text x="736" y="98" font-size="10.5" fill="var(--accent)" opacity=".9">normalized UNIQUE · 판정용</text>
|
||||||
|
<text x="736" y="116" font-size="10.5" fill="var(--accent)" opacity=".9">aliases text[] · 흡수된 표기</text>
|
||||||
|
<text x="736" y="134" font-size="10.5" fill="var(--accent)" opacity=".9">embedding vector(1536) HNSW</text>
|
||||||
|
<text x="736" y="152" font-size="10.5" fill="var(--accent)" opacity=".9">intent · locale</text>
|
||||||
|
<text x="736" y="170" font-size="10.5" fill="var(--accent)" opacity=".9">usage_count</text>
|
||||||
|
|
||||||
|
<text x="736" y="274" font-size="12.5" font-weight="600">qa_pair</text>
|
||||||
|
<text x="736" y="294" font-size="10.5" opacity=".7">question · answer</text>
|
||||||
|
<text x="736" y="312" font-size="10.5" opacity=".7">normalized_question UNIQUE</text>
|
||||||
|
<text x="736" y="330" font-size="10.5" opacity=".7">embedding vector(1536)</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g stroke="currentColor" stroke-width="1.3" fill="none" marker-end="url(#a3)">
|
||||||
|
<line x1="214" y1="63" x2="712" y2="63"/>
|
||||||
|
<line x1="214" y1="147" x2="712" y2="147"/>
|
||||||
|
<line x1="214" y1="276" x2="372" y2="276"/>
|
||||||
|
<path d="M610 262 L666 262 L666 111 L712 111"/>
|
||||||
|
<path d="M119 328 L119 380 L845 380 L845 348"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".65">
|
||||||
|
<text x="463" y="56" text-anchor="middle">업종 분류</text>
|
||||||
|
<text x="463" y="140" text-anchor="middle">지역 분류</text>
|
||||||
|
<text x="293" y="269" text-anchor="middle">1 : N</text>
|
||||||
|
<text x="672" y="205">N : 1</text>
|
||||||
|
<text x="482" y="373" text-anchor="middle">1 : N</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<figcaption>
|
||||||
|
<code>강남 미용실</code> 을 100개 업체가 쓰더라도 <code>keyword</code> 에는 행이 하나, 임베딩도 하나뿐이다.
|
||||||
|
업체별 관련도·성과는 전부 <code>merchant_keyword</code> 가 들고 있으므로 사전을 오염시키지 않고
|
||||||
|
업체마다 다른 순위를 낼 수 있다.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 5 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>API</h2>
|
||||||
|
<p class="lede">
|
||||||
|
<code>:id</code> 는 o2o-site-AEO 의 <code>external_id</code> 와 내부 UUID 를 모두 받는다.
|
||||||
|
연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th style="width:78px">메서드</th><th style="width:300px">경로</th><th>용도</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="mono">GET</td><td class="mono">/health</td><td>헬스체크 · 현재 LLM provider 확인</td></tr>
|
||||||
|
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/publish</td><td><strong>사이트 발행 웹훅.</strong> 업체 upsert 후 생성 작업 적재. <code>sync:true</code> 면 동기 실행</td></tr>
|
||||||
|
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/:id/generate</td><td>수동 재생성. <code>?sync=true</code> 로 결과를 즉시 확인</td></tr>
|
||||||
|
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/seo</td><td><strong>발행 사이트가 렌더링 시 호출.</strong> title · description · keywords · tags(alias 포함)</td></tr>
|
||||||
|
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/aeo</td><td>답변엔진용 topics · FAQ · structuredDataHints</td></tr>
|
||||||
|
<tr><td class="mono">POST</td><td class="mono">/v1/keywords/search</td><td>어드민 — 자연어 질의로 키워드 사전 벡터 검색</td></tr>
|
||||||
|
<tr><td class="mono">POST</td><td class="mono">/v1/sites/:id/performance</td><td>노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<h3>SEO 응답</h3>
|
||||||
|
</div>
|
||||||
|
<pre>$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "레브살롱 | 강남 미용실",
|
||||||
|
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
|
||||||
|
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
|
||||||
|
"tags": [
|
||||||
|
{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
|
||||||
|
"aliases": ["강남미용실"] }
|
||||||
|
]
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<h3>AEO 응답</h3>
|
||||||
|
<p>
|
||||||
|
SEO 가 키워드라면 AEO 는 <strong>질문-답변 쌍과 구조화 데이터</strong>다. AI 검색 크롤러가 인용하는 것은 이쪽이다.
|
||||||
|
<code>structuredDataHints</code> 는 후속 단계에서 <code>LocalBusiness</code> / <code>FAQPage</code> JSON-LD 로 그대로 매핑되도록
|
||||||
|
필드를 미리 맞춰 두었다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre>{
|
||||||
|
"topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
|
||||||
|
"faqs": [
|
||||||
|
{ "question": "레브살롱은(는) 어디에 있나요?",
|
||||||
|
"answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
|
||||||
|
],
|
||||||
|
"structuredDataHints": {
|
||||||
|
"type": "LocalBusiness", "name": "레브살롱",
|
||||||
|
"areaServed": "강남", "category": "미용실"
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 6 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>기술 선택</h2>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:130px">레이어</th><th style="width:250px">선택</th><th>이유</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>런타임</td><td class="mono">NestJS · TypeScript</td><td>o2o-site-AEO 와 payload 타입을 공유할 수 있다</td></tr>
|
||||||
|
<tr><td>DB</td><td class="mono">PostgreSQL 16 + pgvector<br>+ ltree + pg_trgm</td><td>정확 · 의미 · 계층 조회 3-in-1</td></tr>
|
||||||
|
<tr><td>DB 접근</td><td class="mono">postgres.js (raw SQL)</td><td>벡터 연산자 <code><=></code> 와 <code>ltree</code> 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다</td></tr>
|
||||||
|
<tr><td>큐 · 스케줄</td><td class="mono">BullMQ + Redis</td><td>60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장</td></tr>
|
||||||
|
<tr><td>LLM</td><td class="mono">OpenAI Structured Outputs<br>text-embedding-3-small</td><td>JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다</td></tr>
|
||||||
|
<tr><td>관측</td><td class="mono">generation_run 테이블</td><td>프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<h3>로컬 실행</h3>
|
||||||
|
</div>
|
||||||
|
<pre>npm install
|
||||||
|
cp .env.example .env <b># 기본 LLM_PROVIDER=mock — API 키 불필요</b>
|
||||||
|
npm run db:up <b># postgres(pgvector) + redis</b>
|
||||||
|
npm run db:migrate && npm run db:seed
|
||||||
|
npm start <b># http://localhost:3100</b>
|
||||||
|
npm run smoke <b># 다른 터미널 — 엔드투엔드 점검</b></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================= 7 -->
|
||||||
|
<section>
|
||||||
|
<div class="col">
|
||||||
|
<h2>남은 작업</h2>
|
||||||
|
<p class="lede">연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.</p>
|
||||||
|
<ul class="plain">
|
||||||
|
<li><span class="pill pill-go">next</span> JSON-LD 조립 — <code>structuredDataHints</code> → <code>LocalBusiness</code> / <code>FAQPage</code> / <code>Service</code></li>
|
||||||
|
<li><span class="pill pill-go">next</span> <code>/llms.txt</code> 서빙 — AI 검색 크롤러 진입점</li>
|
||||||
|
<li><span class="pill pill-warn">later</span> 업종 <code>ltree</code> 상위 노드 키워드 상속 (<code>source: 'inherited'</code>)</li>
|
||||||
|
<li><span class="pill pill-warn">later</span> Redis 응답 캐시 — 서빙은 읽기 99%, TTL 1시간 + 발행 이벤트 무효화</li>
|
||||||
|
<li><span class="pill pill-warn">later</span> Search Console API 직접 연동 (지금은 <code>/performance</code> 수동 주입)</li>
|
||||||
|
<li><span class="pill pill-warn">later</span> 키워드 승인 · 차단 어드민 UI</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="col">
|
||||||
|
o2o-site-ontology · 설계 문서 · 코드와 함께 <code>docs/architecture.html</code> 에 보관
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
BIN
ontology/docs/site-ontology 아키텍쳐.pptx
Normal file
BIN
ontology/docs/site-ontology 아키텍쳐.pptx
Normal file
Binary file not shown.
124
ontology/drizzle/0000_init.sql
Normal file
124
ontology/drizzle/0000_init.sql
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
-- o2o-site-ontology : initial schema
|
||||||
|
-- pgvector(유사도) + ltree(업종/지역 계층) + pg_trgm(표기 변형) 3-in-1
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS ltree;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 분류 계층
|
||||||
|
CREATE TABLE IF NOT EXISTS industry (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
path ltree NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS industry_path_gist ON industry USING gist (path);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS region (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
path ltree NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS region_path_gist ON region USING gist (path);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 업체
|
||||||
|
CREATE TABLE IF NOT EXISTS merchant (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id text NOT NULL UNIQUE, -- o2o-site-AEO 의 사이트/업체 ID
|
||||||
|
name text NOT NULL,
|
||||||
|
industry_id text REFERENCES industry(id),
|
||||||
|
region_id text REFERENCES region(id),
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
profile jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
site_url text,
|
||||||
|
last_generated_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS merchant_industry_idx ON merchant (industry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS merchant_stale_idx ON merchant (last_generated_at NULLS FIRST);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 전역 키워드 사전
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE keyword_intent AS ENUM
|
||||||
|
('informational','navigational','transactional','local','brand');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS keyword (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
canonical text NOT NULL, -- 화면 노출용 대표 표기
|
||||||
|
normalized text NOT NULL, -- 중복 판정용 정규화 표기
|
||||||
|
locale text NOT NULL DEFAULT 'ko-KR',
|
||||||
|
aliases text[] NOT NULL DEFAULT '{}', -- 흡수된 표기 변형 (롱테일 확보)
|
||||||
|
intent keyword_intent NOT NULL DEFAULT 'informational',
|
||||||
|
industry_id text REFERENCES industry(id),
|
||||||
|
region_id text REFERENCES region(id),
|
||||||
|
embedding vector(1536),
|
||||||
|
usage_count integer NOT NULL DEFAULT 0, -- 몇 개 업체가 쓰는가
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT keyword_normalized_locale_uq UNIQUE (normalized, locale)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS keyword_embedding_hnsw
|
||||||
|
ON keyword USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS keyword_normalized_trgm
|
||||||
|
ON keyword USING gin (normalized gin_trgm_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS keyword_industry_idx ON keyword (industry_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 업체 <-> 키워드
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE merchant_keyword_status AS ENUM
|
||||||
|
('candidate','active','demoted','blocked');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS merchant_keyword (
|
||||||
|
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
|
||||||
|
keyword_id uuid NOT NULL REFERENCES keyword(id) ON DELETE CASCADE,
|
||||||
|
relevance real NOT NULL DEFAULT 0,
|
||||||
|
source text NOT NULL DEFAULT 'llm', -- llm | manual | inherited
|
||||||
|
status merchant_keyword_status NOT NULL DEFAULT 'candidate',
|
||||||
|
rationale text,
|
||||||
|
impressions bigint NOT NULL DEFAULT 0,
|
||||||
|
clicks bigint NOT NULL DEFAULT 0,
|
||||||
|
ctr real NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (merchant_id, keyword_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS merchant_keyword_serving_idx
|
||||||
|
ON merchant_keyword (merchant_id, status, relevance DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- AEO 질문-답변
|
||||||
|
CREATE TABLE IF NOT EXISTS qa_pair (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
|
||||||
|
question text NOT NULL,
|
||||||
|
answer text NOT NULL,
|
||||||
|
normalized_question text NOT NULL,
|
||||||
|
embedding vector(1536),
|
||||||
|
status merchant_keyword_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT qa_pair_merchant_question_uq UNIQUE (merchant_id, normalized_question)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS qa_pair_merchant_idx ON qa_pair (merchant_id, status);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 생성 감사 로그
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_run (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
merchant_id uuid REFERENCES merchant(id) ON DELETE CASCADE,
|
||||||
|
provider text NOT NULL,
|
||||||
|
model text NOT NULL,
|
||||||
|
prompt_version text NOT NULL,
|
||||||
|
trigger text NOT NULL, -- published | scheduled | manual
|
||||||
|
status text NOT NULL DEFAULT 'running', -- running | succeeded | failed
|
||||||
|
input jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
output jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
stats jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
error text,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS generation_run_merchant_idx
|
||||||
|
ON generation_run (merchant_id, started_at DESC);
|
||||||
22
ontology/drizzle/0001_embedding_384.sql
Normal file
22
ontology/drizzle/0001_embedding_384.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- 임베딩 모델 전환: 로컬 multilingual-e5-small (384차원)
|
||||||
|
-- mock/openai 도 384 로 통일한다 (openai 는 dimensions 파라미터로 축소 요청).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
|
||||||
|
WHERE attrelid = 'keyword'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
|
||||||
|
DROP INDEX IF EXISTS keyword_embedding_hnsw;
|
||||||
|
ALTER TABLE keyword ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
|
||||||
|
CREATE INDEX keyword_embedding_hnsw ON keyword USING hnsw (embedding vector_cosine_ops);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
|
||||||
|
WHERE attrelid = 'qa_pair'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
|
||||||
|
ALTER TABLE qa_pair ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 데이터셋 출처 추적 (수작업 큐레이션 / LLM 생성 구분)
|
||||||
|
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'llm';
|
||||||
|
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'keyword';
|
||||||
|
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS category text;
|
||||||
|
CREATE INDEX IF NOT EXISTS keyword_kind_idx ON keyword (kind, category);
|
||||||
8
ontology/nest-cli.json
Normal file
8
ontology/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
6333
ontology/package-lock.json
generated
Normal file
6333
ontology/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
ontology/package.json
Normal file
51
ontology/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "o2o-site-ontology",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "SEO/AEO keyword ontology service for o2o-site-AEO",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"setup": "bash scripts/setup.sh",
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
|
"db:up": "docker compose up -d",
|
||||||
|
"db:down": "docker compose down",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
|
||||||
|
"smoke": "tsx scripts/smoke.ts",
|
||||||
|
"dataset:build": "node scripts/build-dataset.mjs",
|
||||||
|
"dataset:ingest": "tsx scripts/ingest-dataset.ts",
|
||||||
|
"dataset:purge": "tsx scripts/purge-nondataset.ts",
|
||||||
|
"dataset:import-related": "tsx scripts/import-related.ts",
|
||||||
|
"dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py",
|
||||||
|
"dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts",
|
||||||
|
"db:dump": "bash scripts/db-dump.sh",
|
||||||
|
"db:export-xlsx": "python3 scripts/export-db-xlsx.py"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@huggingface/transformers": "^4.2.0",
|
||||||
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
|
"@nestjs/common": "^11.0.12",
|
||||||
|
"@nestjs/core": "^11.0.12",
|
||||||
|
"@nestjs/platform-express": "^11.0.12",
|
||||||
|
"@nestjs/schedule": "^5.0.1",
|
||||||
|
"bullmq": "^5.44.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"openai": "^4.89.0",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.5",
|
||||||
|
"@nestjs/schematics": "^11.0.2",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/node": "^22.13.14",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
333
ontology/public/demo.html
Normal file
333
ontology/public/demo.html
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
|
||||||
|
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
|
||||||
|
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
|
||||||
|
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
|
||||||
|
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
|
||||||
|
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
|
||||||
|
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
|
||||||
|
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
|
||||||
|
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
|
||||||
|
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
|
||||||
|
}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
|
||||||
|
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
|
||||||
|
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
|
||||||
|
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
|
||||||
|
.sub{margin:0;color:var(--muted);font-size:14px}
|
||||||
|
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||||
|
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
|
||||||
|
|
||||||
|
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
|
||||||
|
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
|
||||||
|
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
|
||||||
|
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
||||||
|
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
|
||||||
|
background:var(--surface);color:var(--ink)}
|
||||||
|
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
|
||||||
|
button:disabled{opacity:.5;cursor:progress}
|
||||||
|
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
|
||||||
|
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
|
||||||
|
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
|
||||||
|
.examples button:hover{color:var(--accent);border-color:var(--accent)}
|
||||||
|
|
||||||
|
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
|
||||||
|
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||||||
|
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
|
||||||
|
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
|
||||||
|
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
|
||||||
|
.kv dt{color:var(--muted)}
|
||||||
|
.kv dd{margin:0;color:var(--ink-soft)}
|
||||||
|
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
|
||||||
|
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
|
||||||
|
padding:2px 7px;border-radius:3px}
|
||||||
|
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
|
||||||
|
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
|
||||||
|
|
||||||
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
|
||||||
|
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
|
||||||
|
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
|
||||||
|
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
||||||
|
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||||
|
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||||
|
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
|
||||||
|
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
|
||||||
|
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||||
|
tr:hover td{background:var(--surface2)}
|
||||||
|
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
|
||||||
|
font-variant-numeric:tabular-nums}
|
||||||
|
.kw{font-weight:500}
|
||||||
|
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
|
||||||
|
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
|
||||||
|
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
|
||||||
|
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
|
||||||
|
font-variant-numeric:tabular-nums;text-align:right}
|
||||||
|
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
|
||||||
|
.t-local{background:var(--accent-bg);color:var(--accent)}
|
||||||
|
.t-transactional{background:var(--warn-bg);color:var(--warn)}
|
||||||
|
.t-informational{background:var(--info-bg);color:var(--info)}
|
||||||
|
.t-brand{background:var(--surface2);color:var(--ink-soft)}
|
||||||
|
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
|
||||||
|
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
|
||||||
|
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
|
||||||
|
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
|
||||||
|
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
|
||||||
|
.tblwrap{overflow-x:auto}
|
||||||
|
.modes{display:flex;gap:0;border:1px solid var(--line);border-radius:5px;overflow:hidden}
|
||||||
|
.modes button{border:0;border-radius:0;background:var(--surface);color:var(--muted);font-size:13px;padding:12px 16px;font-weight:500}
|
||||||
|
.modes button[aria-pressed=true]{background:var(--accent);color:#fff}
|
||||||
|
.lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:16px}
|
||||||
|
.lane{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:11px 13px}
|
||||||
|
.lane b{font-size:12.5px}
|
||||||
|
.lane .w{font-family:var(--mono);font-size:10.5px;color:var(--accent);margin-left:5px}
|
||||||
|
.lane .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:5px;line-height:1.55;word-break:break-all}
|
||||||
|
.tabs{display:flex;gap:6px;margin-bottom:12px}
|
||||||
|
.tabs button{font-size:12.5px;padding:6px 13px;border-radius:20px;border:1px solid var(--line);
|
||||||
|
background:var(--surface);color:var(--muted);font-weight:400}
|
||||||
|
.tabs button[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||||
|
.prov{display:inline-flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.prov span{font-family:var(--mono);font-size:10px;background:var(--surface2);color:var(--muted);
|
||||||
|
padding:1px 5px;border-radius:3px}
|
||||||
|
.hold{font-size:10.5px;font-family:var(--mono);color:var(--warn);background:var(--warn-bg);
|
||||||
|
padding:1px 6px;border-radius:3px;white-space:nowrap}
|
||||||
|
.facts{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.facts span{font-family:var(--mono);font-size:11px;background:var(--surface2);color:var(--ink-soft);
|
||||||
|
padding:3px 8px;border-radius:3px}
|
||||||
|
.lanegroup{margin-bottom:20px}
|
||||||
|
.lanegroup h3{margin:0 0 4px;font-size:13.5px}
|
||||||
|
.lanegroup .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin:0 0 8px}
|
||||||
|
.exrow td{color:var(--muted)}
|
||||||
|
.exwhy{font-family:var(--mono);font-size:11px;color:var(--stop)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
|
||||||
|
<h1>키워드 매칭 콘솔</h1>
|
||||||
|
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
|
||||||
|
<div class="status" id="status"><span>연결 확인 중…</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="f">
|
||||||
|
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
|
||||||
|
<div class="modes">
|
||||||
|
<button type="button" id="m-fusion" aria-pressed="true">융합</button>
|
||||||
|
<button type="button" id="m-single" aria-pressed="false">통짜</button>
|
||||||
|
</div>
|
||||||
|
<select id="limit">
|
||||||
|
<option value="30">상위 30</option>
|
||||||
|
<option value="50" selected>상위 50</option>
|
||||||
|
<option value="100">상위 100</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" id="go">매칭</button>
|
||||||
|
</form>
|
||||||
|
<div class="examples" id="ex"></div>
|
||||||
|
|
||||||
|
<div id="err"></div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
|
||||||
|
<section>
|
||||||
|
<div class="lanes" id="lanes"></div>
|
||||||
|
<div class="tabs" id="tabs"></div>
|
||||||
|
<div class="toolbar" id="filters"></div>
|
||||||
|
<div class="card" style="padding:0">
|
||||||
|
<div class="tblwrap"><table id="tbl">
|
||||||
|
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
|
||||||
|
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = location.origin;
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
let LAST = null, FILTER = null, MODE = 'fusion', TAB = 'fused';
|
||||||
|
|
||||||
|
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
|
||||||
|
'아이랑 물놀이 하기 좋은 곳', '말랭이마을 걸어서 갈 수 있는 숙소'];
|
||||||
|
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
|
||||||
|
$('#ex').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('button'); if (!b) return;
|
||||||
|
$('#q').value = b.dataset.q; run();
|
||||||
|
});
|
||||||
|
for (const m of ['fusion', 'single']) {
|
||||||
|
$('#m-' + m).addEventListener('click', () => {
|
||||||
|
MODE = m;
|
||||||
|
$('#m-fusion').setAttribute('aria-pressed', String(m === 'fusion'));
|
||||||
|
$('#m-single').setAttribute('aria-pressed', String(m === 'single'));
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function health() {
|
||||||
|
try {
|
||||||
|
const h = await (await fetch(API + '/health')).json();
|
||||||
|
$('#status').innerHTML =
|
||||||
|
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
|
||||||
|
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const query = $('#q').value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
$('#go').disabled = true; $('#err').innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/v1/match', {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query, limit: Number($('#limit').value), mode: MODE }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
LAST = await res.json(); FILTER = null; TAB = 'fused';
|
||||||
|
renderSide(); renderLanes(); renderTabs(); renderBody();
|
||||||
|
} catch (e) {
|
||||||
|
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
|
||||||
|
} finally { $('#go').disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSide() {
|
||||||
|
const m = LAST.resolved;
|
||||||
|
if (!m) {
|
||||||
|
$('#side').innerHTML = `<h2>업체 미해석</h2>
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin:0">일치하는 업체가 없어 입력 문장을 그대로 질의로 씁니다.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = m.profile || {};
|
||||||
|
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
|
||||||
|
const chips = (k, label) => arr(k).length
|
||||||
|
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
|
||||||
|
const f = LAST.facts;
|
||||||
|
$('#side').innerHTML = `
|
||||||
|
<h2>해석된 업체</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
|
||||||
|
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
|
||||||
|
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
|
||||||
|
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
|
||||||
|
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
|
||||||
|
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
|
||||||
|
</dl>
|
||||||
|
${f ? `<div class="qtext"><b>필터에 쓰는 사실</b>
|
||||||
|
<div class="facts">
|
||||||
|
<span>권역 ${esc(f.areaGroup || '미상')}</span>
|
||||||
|
<span>최대 ${f.capacityMax ?? '?'}인</span>
|
||||||
|
${f.amenities.map(a => `<span>${esc(a)}</span>`).join('')}
|
||||||
|
${f.unverified.map(u => `<span style="color:var(--warn)">${esc(u)}?</span>`).join('')}
|
||||||
|
</div></div>` : ''}
|
||||||
|
${LAST.mode === 'single' ? `<div class="qtext"><b>임베딩에 사용한 질의문 (통짜)</b><br>${esc(LAST.queryText)}</div>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLanes() {
|
||||||
|
if (LAST.mode !== 'fusion') { $('#lanes').innerHTML = ''; return; }
|
||||||
|
$('#lanes').innerHTML = LAST.lanes.map(l => `
|
||||||
|
<div class="lane">
|
||||||
|
<b>${esc(l.label)}</b><span class="w">w=${l.weight}</span>
|
||||||
|
<div class="q">${esc(l.text)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabs() {
|
||||||
|
if (LAST.mode !== 'fusion') { $('#tabs').innerHTML = ''; return; }
|
||||||
|
const tabs = [['fused', `융합 순위 ${LAST.matches.length}`],
|
||||||
|
['lanes', '레인별 (페이지 배분)'],
|
||||||
|
['excluded', `배제됨 ${LAST.excludedTotal}`]];
|
||||||
|
$('#tabs').innerHTML = tabs.map(([k, label]) =>
|
||||||
|
`<button data-t="${k}" aria-pressed="${k === TAB}">${label}</button>`).join('');
|
||||||
|
$('#tabs').onclick = (e) => {
|
||||||
|
const b = e.target.closest('button'); if (!b) return;
|
||||||
|
TAB = b.dataset.t;
|
||||||
|
[...$('#tabs').querySelectorAll('button')].forEach(x => x.setAttribute('aria-pressed', String(x.dataset.t === TAB)));
|
||||||
|
renderBody();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody() {
|
||||||
|
if (LAST.mode === 'fusion' && TAB === 'lanes') return renderLaneGroups();
|
||||||
|
if (LAST.mode === 'fusion' && TAB === 'excluded') return renderExcluded();
|
||||||
|
renderFilters(); renderTable(LAST.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilters() {
|
||||||
|
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
|
||||||
|
$('#filters').innerHTML =
|
||||||
|
`<button class="filter" data-c="" aria-pressed="${!FILTER}">전체</button>` +
|
||||||
|
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="${FILTER === c}">${esc(c)}</button>`).join('') +
|
||||||
|
`<span class="count">사전 ${LAST.total.toLocaleString()}건 · ${LAST.mode === 'fusion' ? '융합' : '통짜'}</span>`;
|
||||||
|
$('#filters').onclick = (e) => {
|
||||||
|
const b = e.target.closest('.filter'); if (!b) return;
|
||||||
|
FILTER = b.dataset.c || null;
|
||||||
|
[...$('#filters').querySelectorAll('.filter')]
|
||||||
|
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
|
||||||
|
renderTable(LAST.matches);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(r, i, metric) {
|
||||||
|
const val = metric === 'rrf' ? r.rrf : r.score;
|
||||||
|
return `<tr>
|
||||||
|
<td class="rank">${i + 1}</td>
|
||||||
|
<td class="kw">${esc(r.canonical)}
|
||||||
|
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
|
||||||
|
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
|
||||||
|
${r.status === 'hold' ? `<span class="hold">보류 · ${esc(r.holdReason || '')}</span>` : ''}
|
||||||
|
${r.lanes ? `<small class="prov">${r.lanes.slice(0, 4).map(l => `<span>${esc(l.label)}${l.rank}</span>`).join('')}</small>` : ''}
|
||||||
|
</td>
|
||||||
|
<td><span class="score">${metric === 'rrf' ? val.toFixed(5) : val.toFixed(4)}</span></td>
|
||||||
|
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
|
||||||
|
<td class="cat">${esc(r.category || '-')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows) {
|
||||||
|
const list = rows.filter(m => !FILTER || m.category === FILTER);
|
||||||
|
const metric = LAST.mode === 'fusion' ? 'rrf' : 'cos';
|
||||||
|
$('#tbl thead').innerHTML =
|
||||||
|
`<tr><th class="rank">#</th><th>키워드</th><th>${metric === 'rrf' ? 'RRF' : '유사도'}</th><th>의도</th><th>카테고리</th></tr>`;
|
||||||
|
$('#tbl tbody').innerHTML = list.length
|
||||||
|
? list.map((r, i) => row(r, i, metric)).join('')
|
||||||
|
: '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLaneGroups() {
|
||||||
|
$('#filters').innerHTML = '<span class="count">레인 1위가 그 페이지의 주력 키워드가 된다</span>';
|
||||||
|
$('#tbl').closest('.card').innerHTML = '<div style="padding:18px">' + LAST.byLane.map(l => `
|
||||||
|
<div class="lanegroup">
|
||||||
|
<h3>${esc(l.label)} <span class="chip">w=${l.weight}</span></h3>
|
||||||
|
<p class="q">${esc(l.text)}</p>
|
||||||
|
<table><tbody>${l.items.map((r, i) => row(r, i, 'cos')).join('')}</tbody></table>
|
||||||
|
</div>`).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExcluded() {
|
||||||
|
$('#filters').innerHTML = `<span class="count">사실 기반 필터로 걸러낸 ${LAST.excludedTotal}건 — 벡터만으로는 못 거른다</span>`;
|
||||||
|
$('#tbl thead').innerHTML = '<tr><th class="rank">#</th><th>키워드</th><th colspan="3">배제 사유</th></tr>';
|
||||||
|
$('#tbl tbody').innerHTML = LAST.excluded.length
|
||||||
|
? LAST.excluded.map((e, i) => `<tr class="exrow"><td class="rank">${i + 1}</td>
|
||||||
|
<td class="kw">${esc(e.canonical)}</td>
|
||||||
|
<td colspan="3" class="exwhy">${esc(e.reason)}</td></tr>`).join('')
|
||||||
|
: '<tr><td colspan="5"><div class="empty">배제된 항목 없음</div></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
|
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
|
||||||
|
health(); run();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
204
ontology/scripts/build-dataset.mjs
Normal file
204
ontology/scripts/build-dataset.mjs
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
|
||||||
|
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
|
||||||
|
*
|
||||||
|
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
|
||||||
|
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
|
||||||
|
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
|
||||||
|
*/
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
|
||||||
|
const REGION = '군산';
|
||||||
|
|
||||||
|
// 고군산군도·해안 권역
|
||||||
|
const ISLANDS = ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도'];
|
||||||
|
// 시내·주요 권역
|
||||||
|
const AREAS = ['새만금', '비응항', '오식도', '은파호수공원', '월명동', '나운동', '수송동', '미룡동', '옥도면',
|
||||||
|
'군산 원도심', '신흥동', '영화동'];
|
||||||
|
// 관광지 (근처 숙소 검색의 앵커)
|
||||||
|
const SPOTS = [
|
||||||
|
'선유도해수욕장', '새만금방조제', '경암동 철길마을', '근대역사박물관', '초원사진관',
|
||||||
|
'이성당', '동국사', '진포해양테마공원', '신흥동 일본식가옥', '은파호수공원',
|
||||||
|
'월명공원', '금강하구둑', '철새조망대', '채만식문학관', '째보선창', '시간여행마을',
|
||||||
|
'말랭이마을', '군산 근대문화역사거리', '해망굴', '군산항 뜬다리부두',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 숙소 유형
|
||||||
|
const STAY_CORE = ['펜션', '숙소', '풀빌라', '독채펜션', '스파펜션', '애견펜션', '감성펜션'];
|
||||||
|
const STAY_ALT = ['글램핑', '카라반', '캠핑장', '게스트하우스', '민박', '리조트', '한옥펜션', '촌집', '별장',
|
||||||
|
'독채스테이', '감성숙소', '스테이', '일본식가옥 숙소'];
|
||||||
|
|
||||||
|
// 검색 의도어
|
||||||
|
const INTENT_CORE = ['추천', '예약', '가격', '후기', '순위'];
|
||||||
|
const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약', '당일예약', '특가', '할인',
|
||||||
|
'위치', '주차', '전화번호', '체크인시간', '조식포함', '청소상태'];
|
||||||
|
|
||||||
|
// 동반자
|
||||||
|
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
|
||||||
|
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
|
||||||
|
'회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인'];
|
||||||
|
|
||||||
|
// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어
|
||||||
|
const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집',
|
||||||
|
'깔끔한', '신축', '프라이빗한', '혼자 있기 좋은'];
|
||||||
|
|
||||||
|
// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다
|
||||||
|
const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행',
|
||||||
|
'워케이션', '무박', '한달살기'];
|
||||||
|
|
||||||
|
// 시설·특징
|
||||||
|
const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
|
||||||
|
const FEAT_MORE = ['노을뷰', '일출뷰', '월풀', '온수풀', '야외수영장', '인피니티풀', '불멍', '화로대',
|
||||||
|
'넷플릭스', '빔프로젝터', '노래방', '파티룸', '복층', '테라스', '마당', '벽난로',
|
||||||
|
'애견운동장', '키즈룸', '트램폴린', '무료주차', '조식', '세미나실'];
|
||||||
|
|
||||||
|
// 시즌·행사
|
||||||
|
const SEASON = ['여름휴가', '물놀이', '해수욕', '겨울', '연말', '크리스마스', '신정', '설날', '추석',
|
||||||
|
'봄', '벚꽃', '가을', '단풍', '일출', '낙조', '불꽃놀이', '성수기', '비수기', '주말', '평일'];
|
||||||
|
const OCCASION = ['생일', '기념일', '결혼기념일', '프러포즈', '100일', '가족여행', '워크샵', '단합대회'];
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────── 방출
|
||||||
|
const rows = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const INTENT_MAP = {
|
||||||
|
예약: 'transactional', 실시간예약: 'transactional', 당일예약: 'transactional',
|
||||||
|
가격: 'transactional', 최저가: 'transactional', 특가: 'transactional', 할인: 'transactional',
|
||||||
|
후기: 'informational', 순위: 'informational', 청소상태: 'informational', 체크인시간: 'informational',
|
||||||
|
};
|
||||||
|
const intentOf = (m) => INTENT_MAP[m] ?? 'local';
|
||||||
|
|
||||||
|
function add(keyword, { intent = 'local', kind = 'keyword', category, relevance }) {
|
||||||
|
const k = keyword.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!k || seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
rows.push({ keyword: k, intent, kind, category, relevance: Math.round(relevance * 100) / 100 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T1 — 코어: 지역 × 숙소유형 × 의도
|
||||||
|
for (const s of STAY_CORE) add(`${REGION} ${s}`, { category: '코어', relevance: 0.97 });
|
||||||
|
for (const s of STAY_CORE) for (const m of INTENT_CORE)
|
||||||
|
add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 });
|
||||||
|
for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 });
|
||||||
|
for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 });
|
||||||
|
// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다
|
||||||
|
for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박'])
|
||||||
|
add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 });
|
||||||
|
|
||||||
|
// T2 — 섬·권역 × 숙소유형
|
||||||
|
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
|
||||||
|
add(`${p} ${s}`, { category: '권역', relevance: g === ISLANDS ? 0.9 : 0.85 });
|
||||||
|
for (const p of ISLANDS) for (const m of INTENT_CORE)
|
||||||
|
add(`${p} 펜션 ${m}`, { intent: intentOf(m), category: '권역', relevance: 0.82 });
|
||||||
|
|
||||||
|
// T3 — 동반자
|
||||||
|
for (const w of WITH_CORE) {
|
||||||
|
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.91 });
|
||||||
|
for (const m of INTENT_CORE) add(`${REGION} ${w} 펜션 ${m}`, { intent: intentOf(m), category: '동반자', relevance: 0.8 });
|
||||||
|
for (const s of STAY_CORE.slice(2, 6)) add(`${REGION} ${w} ${s}`, { category: '동반자', relevance: 0.78 });
|
||||||
|
}
|
||||||
|
for (const w of WITH_MORE) {
|
||||||
|
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.82 });
|
||||||
|
add(`${REGION} ${w} 펜션 추천`, { category: '동반자', relevance: 0.75 });
|
||||||
|
add(`${REGION} ${w} 숙소`, { category: '동반자', relevance: 0.73 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T4 — 시설·특징
|
||||||
|
for (const f of FEAT_CORE) {
|
||||||
|
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.89 });
|
||||||
|
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.76 });
|
||||||
|
for (const m of INTENT_CORE.slice(0, 3)) add(`${REGION} ${f} 펜션 ${m}`, { intent: intentOf(m), category: '시설', relevance: 0.72 });
|
||||||
|
}
|
||||||
|
for (const f of FEAT_MORE) {
|
||||||
|
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.79 });
|
||||||
|
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.7 });
|
||||||
|
}
|
||||||
|
for (const p of ISLANDS.slice(0, 4)) for (const f of FEAT_CORE)
|
||||||
|
add(`${p} ${f} 펜션`, { category: '시설', relevance: 0.74 });
|
||||||
|
|
||||||
|
// T5 — 시즌·행사
|
||||||
|
for (const s of SEASON) {
|
||||||
|
add(`${REGION} ${s} 펜션`, { category: '시즌', relevance: 0.8 });
|
||||||
|
add(`${REGION} ${s} 펜션 예약`, { intent: 'transactional', category: '시즌', relevance: 0.71 });
|
||||||
|
add(`${s} ${REGION} 숙소`, { category: '시즌', relevance: 0.68 });
|
||||||
|
}
|
||||||
|
for (const o of OCCASION) {
|
||||||
|
add(`${REGION} ${o} 펜션`, { category: '시즌', relevance: 0.75 });
|
||||||
|
add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T5.5 — 분위기·여행형태
|
||||||
|
for (const v of VIBE) {
|
||||||
|
add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 });
|
||||||
|
add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 });
|
||||||
|
add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 });
|
||||||
|
}
|
||||||
|
for (const t of TRAVEL) {
|
||||||
|
add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 });
|
||||||
|
add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 });
|
||||||
|
}
|
||||||
|
for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3))
|
||||||
|
add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 });
|
||||||
|
|
||||||
|
// T6 — 관광지 앵커
|
||||||
|
for (const sp of SPOTS) {
|
||||||
|
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
|
||||||
|
add(`${sp} 근처 숙소`, { category: '관광지', relevance: 0.8 });
|
||||||
|
add(`${sp} 펜션 추천`, { category: '관광지', relevance: 0.72 });
|
||||||
|
add(`${sp} 가까운 숙소`, { category: '관광지', relevance: 0.66 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T7 — 태그 (칩 UI 용 짧은 패싯)
|
||||||
|
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
|
||||||
|
'오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능',
|
||||||
|
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷',
|
||||||
|
...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심'];
|
||||||
|
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
|
||||||
|
|
||||||
|
// T8 — 질문형 (AEO)
|
||||||
|
const Q = [];
|
||||||
|
for (const w of [...WITH_CORE, '아이', '부모님']) Q.push([`${REGION} ${w} 펜션 어디가 좋아요`, 'informational', 0.7]);
|
||||||
|
for (const f of FEAT_CORE) Q.push([`${REGION}에 ${f} 펜션 있나요`, 'informational', 0.67]);
|
||||||
|
for (const p of ISLANDS.slice(0, 5)) {
|
||||||
|
Q.push([`${p} 펜션 어떻게 가나요`, 'informational', 0.64]);
|
||||||
|
Q.push([`${p} 숙소 예약 언제 해야 하나요`, 'informational', 0.6]);
|
||||||
|
}
|
||||||
|
Q.push([`${REGION} 펜션 1박 얼마인가요`, 'transactional', 0.72]);
|
||||||
|
Q.push([`${REGION} 펜션 바베큐 가능한가요`, 'informational', 0.7]);
|
||||||
|
Q.push([`${REGION} 펜션 체크인 몇시인가요`, 'informational', 0.66]);
|
||||||
|
Q.push([`${REGION} 펜션 주차 되나요`, 'informational', 0.66]);
|
||||||
|
Q.push([`${REGION} 애견동반 펜션 추가요금 있나요`, 'informational', 0.63]);
|
||||||
|
Q.push([`${REGION} 펜션 성수기 언제인가요`, 'informational', 0.61]);
|
||||||
|
Q.push([`선유도 들어가는 배 시간표`, 'informational', 0.55]);
|
||||||
|
Q.push([`${REGION} 여행 몇박이 좋을까요`, 'informational', 0.54]);
|
||||||
|
for (const [k, i, r] of Q) add(k, { intent: i, category: '질문형', relevance: r });
|
||||||
|
|
||||||
|
// T9 — 롱테일: 동반자 × 시설 / 권역 × 동반자 / 시즌 × 동반자
|
||||||
|
const LONGTAIL = [];
|
||||||
|
for (const w of WITH_CORE) for (const f of FEAT_CORE) LONGTAIL.push([`${REGION} ${w} ${f} 펜션`, 0.5]);
|
||||||
|
for (const p of ISLANDS) for (const w of WITH_CORE) LONGTAIL.push([`${p} ${w} 펜션`, 0.48]);
|
||||||
|
for (const s of SEASON) for (const w of WITH_CORE) LONGTAIL.push([`${REGION} ${s} ${w} 펜션`, 0.44]);
|
||||||
|
for (const f of FEAT_CORE) for (const f2 of FEAT_MORE) LONGTAIL.push([`${REGION} ${f} ${f2} 펜션`, 0.4]);
|
||||||
|
for (const a of AREAS) for (const f of FEAT_CORE) LONGTAIL.push([`${a} ${f} 펜션`, 0.42]);
|
||||||
|
for (const [k, r] of LONGTAIL) {
|
||||||
|
if (rows.length >= 1000) break;
|
||||||
|
add(k, { category: '롱테일', relevance: r });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataset = rows.slice(0, 1000);
|
||||||
|
mkdirSync('data', { recursive: true });
|
||||||
|
writeFileSync('data/gunsan-pension-keywords.json', JSON.stringify({
|
||||||
|
topic: '군산 펜션',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
generatedBy: 'hand-authored vocabulary × search-pattern expansion',
|
||||||
|
count: dataset.length,
|
||||||
|
items: dataset,
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
|
||||||
|
const by = (f) => dataset.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
|
||||||
|
console.log(`✅ data/gunsan-pension-keywords.json ${dataset.length}건`);
|
||||||
|
console.log(' 카테고리:', by('category'));
|
||||||
|
console.log(' 의도 :', by('intent'));
|
||||||
|
console.log(' 종류 :', by('kind'));
|
||||||
524
ontology/scripts/build-deck.py
Normal file
524
ontology/scripts/build-deck.py
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.
|
||||||
|
python3 scripts/build-deck.py
|
||||||
|
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
|
||||||
|
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches, Pt
|
||||||
|
from pptx.dml.color import RGBColor
|
||||||
|
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
||||||
|
from pptx.oxml.ns import qn
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
|
||||||
|
INK = RGBColor(0x10, 0x18, 0x19)
|
||||||
|
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
|
||||||
|
MUTED = RGBColor(0x63, 0x75, 0x7A)
|
||||||
|
LINE = RGBColor(0xD5, 0xDC, 0xDB)
|
||||||
|
BG = RGBColor(0xF4, 0xF6, 0xF5)
|
||||||
|
SURFACE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||||
|
SURF2 = RGBColor(0xEC, 0xEF, 0xF0)
|
||||||
|
ACCENT = RGBColor(0x0D, 0x6A, 0x60)
|
||||||
|
ACC_BG = RGBColor(0xDF, 0xF0, 0xEC)
|
||||||
|
WARN = RGBColor(0x8A, 0x5A, 0x06)
|
||||||
|
WARN_BG = RGBColor(0xF6, 0xEA, 0xD2)
|
||||||
|
STOP = RGBColor(0x9D, 0x3A, 0x30)
|
||||||
|
STOP_BG = RGBColor(0xF6, 0xE0, 0xDC)
|
||||||
|
|
||||||
|
SANS = 'Apple SD Gothic Neo' # macOS 기본 한글 산세리프
|
||||||
|
MONO = 'Menlo'
|
||||||
|
|
||||||
|
W, H = 13.333, 7.5
|
||||||
|
MX = 0.75 # 좌우 여백
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 저수준 헬퍼
|
||||||
|
def _ea(run, name):
|
||||||
|
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
|
||||||
|
rPr = run._r.get_or_add_rPr()
|
||||||
|
for tag in ('a:ea', 'a:cs'):
|
||||||
|
el = rPr.find(qn(tag))
|
||||||
|
if el is None:
|
||||||
|
el = rPr.makeelement(qn(tag), {})
|
||||||
|
rPr.append(el)
|
||||||
|
el.set('typeface', name)
|
||||||
|
|
||||||
|
|
||||||
|
def write(tf, lines, space=2):
|
||||||
|
"""lines: [(text, size, bold, color, font)] — 첫 줄은 기존 문단 재사용."""
|
||||||
|
tf.word_wrap = True
|
||||||
|
for i, spec in enumerate(lines):
|
||||||
|
text, size, bold, color = spec[0], spec[1], spec[2], spec[3]
|
||||||
|
font = spec[4] if len(spec) > 4 else SANS
|
||||||
|
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||||
|
p.space_after = Pt(space)
|
||||||
|
p.line_spacing = 1.15
|
||||||
|
r = p.add_run()
|
||||||
|
r.text = text
|
||||||
|
r.font.size = Pt(size)
|
||||||
|
r.font.bold = bold
|
||||||
|
r.font.color.rgb = color
|
||||||
|
r.font.name = font
|
||||||
|
_ea(r, font if font != MONO else SANS)
|
||||||
|
return tf
|
||||||
|
|
||||||
|
|
||||||
|
def textbox(sl, x, y, w, h, lines, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, space=2):
|
||||||
|
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||||
|
tf = tb.text_frame
|
||||||
|
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
||||||
|
tf.vertical_anchor = anchor
|
||||||
|
write(tf, lines, space)
|
||||||
|
for p in tf.paragraphs:
|
||||||
|
p.alignment = align
|
||||||
|
return tb
|
||||||
|
|
||||||
|
|
||||||
|
def box(sl, x, y, w, h, lines=None, fill=SURF2, line=LINE, lw=1.0,
|
||||||
|
pad=0.12, anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT, space=2, rounded=True):
|
||||||
|
shape = sl.shapes.add_shape(
|
||||||
|
MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
|
||||||
|
Inches(x), Inches(y), Inches(w), Inches(h))
|
||||||
|
if fill is None:
|
||||||
|
shape.fill.background()
|
||||||
|
else:
|
||||||
|
shape.fill.solid()
|
||||||
|
shape.fill.fore_color.rgb = fill
|
||||||
|
if line is None:
|
||||||
|
shape.line.fill.background()
|
||||||
|
else:
|
||||||
|
shape.line.color.rgb = line
|
||||||
|
shape.line.width = Pt(lw)
|
||||||
|
if rounded:
|
||||||
|
shape.adjustments[0] = 0.09
|
||||||
|
shape.shadow.inherit = False
|
||||||
|
tf = shape.text_frame
|
||||||
|
tf.margin_left = tf.margin_right = Inches(pad)
|
||||||
|
tf.margin_top = tf.margin_bottom = Inches(pad * 0.7)
|
||||||
|
tf.vertical_anchor = anchor
|
||||||
|
if lines:
|
||||||
|
write(tf, lines, space)
|
||||||
|
for p in tf.paragraphs:
|
||||||
|
p.alignment = align
|
||||||
|
return shape
|
||||||
|
|
||||||
|
|
||||||
|
def arrow(sl, x1, y1, x2, y2, color=INK, width=1.25, dash=False):
|
||||||
|
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
|
||||||
|
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
|
||||||
|
c.line.color.rgb = color
|
||||||
|
c.line.width = Pt(width)
|
||||||
|
ln = c.line._get_or_add_ln()
|
||||||
|
if dash:
|
||||||
|
ln.append(ln.makeelement(qn('a:prstDash'), {'val': 'dash'}))
|
||||||
|
ln.append(ln.makeelement(qn('a:tailEnd'), {'type': 'triangle', 'w': 'med', 'len': 'med'}))
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def elbow(sl, pts, color=INK, width=1.25, dash=False):
|
||||||
|
"""직교 경로: 마지막 구간에만 화살촉."""
|
||||||
|
for i in range(len(pts) - 1):
|
||||||
|
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
||||||
|
if i == len(pts) - 2:
|
||||||
|
arrow(sl, x1, y1, x2, y2, color, width, dash)
|
||||||
|
else:
|
||||||
|
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
|
||||||
|
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
|
||||||
|
c.line.color.rgb = color
|
||||||
|
c.line.width = Pt(width)
|
||||||
|
if dash:
|
||||||
|
c.line._get_or_add_ln().append(
|
||||||
|
c.line._get_or_add_ln().makeelement(qn('a:prstDash'), {'val': 'dash'}))
|
||||||
|
|
||||||
|
|
||||||
|
def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w=2.4):
|
||||||
|
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 슬라이드 골격
|
||||||
|
prs = Presentation()
|
||||||
|
prs.slide_width = Inches(W)
|
||||||
|
prs.slide_height = Inches(H)
|
||||||
|
BLANK = prs.slide_layouts[6]
|
||||||
|
|
||||||
|
|
||||||
|
def slide(title=None, lede=None, eyebrow=None):
|
||||||
|
sl = prs.slides.add_slide(BLANK)
|
||||||
|
bg = sl.background.fill
|
||||||
|
bg.solid()
|
||||||
|
bg.fore_color.rgb = BG
|
||||||
|
y = 0.42
|
||||||
|
if eyebrow:
|
||||||
|
textbox(sl, MX, y, 8, 0.22, [(eyebrow.upper(), 9, True, ACCENT, MONO)])
|
||||||
|
y += 0.30
|
||||||
|
if title:
|
||||||
|
textbox(sl, MX, y, W - 2 * MX, 0.5, [(title, 26, True, INK)])
|
||||||
|
y += 0.62
|
||||||
|
if lede:
|
||||||
|
textbox(sl, MX, y, W - 2 * MX - 1.2, 0.4, [(lede, 12.5, False, MUTED)])
|
||||||
|
y += 0.46
|
||||||
|
return sl, y + 0.22
|
||||||
|
|
||||||
|
|
||||||
|
def table(sl, x, y, w, cols, rows, widths=None, fs=10.5, hfs=8.5, rowh=0.34):
|
||||||
|
shape = sl.shapes.add_table(len(rows) + 1, len(cols), Inches(x), Inches(y),
|
||||||
|
Inches(w), Inches(rowh * (len(rows) + 1)))
|
||||||
|
t = shape.table
|
||||||
|
t.first_row = False
|
||||||
|
if widths:
|
||||||
|
for i, ww in enumerate(widths):
|
||||||
|
t.columns[i].width = Inches(ww)
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
cell = t.cell(0, i)
|
||||||
|
cell.fill.solid(); cell.fill.fore_color.rgb = BG
|
||||||
|
cell.margin_left = cell.margin_right = Inches(0.09)
|
||||||
|
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
write(cell.text_frame, [(c.upper(), hfs, True, MUTED, MONO)])
|
||||||
|
for r, row in enumerate(rows, start=1):
|
||||||
|
t.rows[r].height = Inches(rowh)
|
||||||
|
for i, val in enumerate(row):
|
||||||
|
cell = t.cell(r, i)
|
||||||
|
cell.fill.solid(); cell.fill.fore_color.rgb = SURFACE
|
||||||
|
cell.margin_left = cell.margin_right = Inches(0.09)
|
||||||
|
cell.margin_top = cell.margin_bottom = Inches(0.04)
|
||||||
|
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
mono = val.startswith('`')
|
||||||
|
write(cell.text_frame,
|
||||||
|
[(val.lstrip('`'), fs, False, INK_SOFT, MONO if mono else SANS)])
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def footer(sl, n):
|
||||||
|
textbox(sl, MX, H - 0.52, 6, 0.24,
|
||||||
|
[('o2o-site-ontology', 8.5, False, MUTED, MONO)])
|
||||||
|
textbox(sl, W - MX - 1.2, H - 0.52, 1.2, 0.24,
|
||||||
|
[(f'{n:02d}', 8.5, False, MUTED, MONO)], align=PP_ALIGN.RIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ 1 표지
|
||||||
|
sl = prs.slides.add_slide(BLANK)
|
||||||
|
sl.background.fill.solid(); sl.background.fill.fore_color.rgb = BG
|
||||||
|
box(sl, 0, 0, 0.16, H, fill=ACCENT, line=None, rounded=False)
|
||||||
|
textbox(sl, 1.3, 1.95, 10, 0.3, [('O2O-SITE-ONTOLOGY', 10.5, True, ACCENT, MONO)])
|
||||||
|
textbox(sl, 1.3, 2.35, 11, 1.5,
|
||||||
|
[('발행 사이트에 붙는', 40, True, INK), ('SEO/AEO 키워드 온톨로지', 40, True, INK)], space=4)
|
||||||
|
textbox(sl, 1.3, 4.15, 8.6, 1.0,
|
||||||
|
[('업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.', 13, False, INK_SOFT),
|
||||||
|
('LLM 이 후보를 만들고, 4단계 중복제거가 전역 사전을 깨끗하게 유지하고,', 13, False, INK_SOFT),
|
||||||
|
('발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.', 13, False, INK_SOFT)], space=3)
|
||||||
|
for i, chip in enumerate(['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs']):
|
||||||
|
wch = 0.16 + len(chip) * 0.082
|
||||||
|
box(sl, 1.3 + sum(0.16 + len(c) * 0.082 + 0.14 for c in
|
||||||
|
['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs'][:i]),
|
||||||
|
5.5, wch, 0.32, [(chip, 9, False, MUTED, MONO)],
|
||||||
|
fill=SURFACE, line=LINE, pad=0.08, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER)
|
||||||
|
textbox(sl, 1.3, 6.5, 8, 0.24, [('설계 문서 · 로컬 구현 검증 완료', 9.5, False, MUTED, MONO)])
|
||||||
|
|
||||||
|
# ================================================================ 2 DB 선택
|
||||||
|
sl, y = slide('일반 DB 냐 벡터 DB 냐', '둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.', '설계 판단 1')
|
||||||
|
table(sl, MX, y, W - 2 * MX,
|
||||||
|
['조회 유형', '실제 질의', '필요한 것'],
|
||||||
|
[['정확 조회', '업체 A 의 활성 키워드 20개', '`B-tree / 관계형 조인'],
|
||||||
|
['의미 조회', '이 후보가 기존 키워드와 의미상 겹치는가', '`vector (HNSW)'],
|
||||||
|
['관계 탐색', '업종 트리 상위에서 물려받을 공통 키워드', '`ltree 계층 / recursive CTE']],
|
||||||
|
widths=[2.3, 5.9, 3.633], rowh=0.42)
|
||||||
|
yy = y + 2.0
|
||||||
|
box(sl, MX, yy, 0.06, 1.55, fill=ACCENT, line=None, rounded=False)
|
||||||
|
box(sl, MX + 0.06, yy, W - 2 * MX - 0.06, 1.55,
|
||||||
|
[('결론 — PostgreSQL 하나로 시작한다.', 14, True, ACCENT),
|
||||||
|
('pgvector + ltree + pg_trgm + JSONB 로 세 가지가 한 엔진 안에서 해결되고, 무엇보다', 11.5, False, INK_SOFT),
|
||||||
|
('키워드 조회에는 항상 "어느 업체의" 라는 조인이 따라붙는다.', 11.5, True, INK_SOFT),
|
||||||
|
('', 6, False, INK_SOFT),
|
||||||
|
('전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. 벡터 행이 1천만 건을', 11.5, False, INK_SOFT),
|
||||||
|
('넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.', 11.5, False, INK_SOFT)],
|
||||||
|
fill=ACC_BG, line=None, pad=0.24, space=3, rounded=False)
|
||||||
|
footer(sl, 2)
|
||||||
|
|
||||||
|
# ================================================================ 3 전체 흐름
|
||||||
|
sl, y = slide('전체 흐름', '생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.', '아키텍처')
|
||||||
|
BW, BH = 2.15, 1.05
|
||||||
|
xs = [MX, MX + 2.5, MX + 5.0, MX + 7.5, MX + 10.0]
|
||||||
|
r1 = y + 0.42
|
||||||
|
box(sl, xs[0], r1, BW, BH,
|
||||||
|
[('트리거', 11.5, True, INK), ('사이트 발행 — 즉시', 9, False, MUTED),
|
||||||
|
('크론 03:00 — 30일 경과', 9, False, MUTED), ('성과 저조 — 재생성', 9, False, MUTED)], space=1)
|
||||||
|
box(sl, xs[1], r1, BW, BH,
|
||||||
|
[('BullMQ 큐', 11.5, True, INK), ('60초 dedupe 창', 9, False, MUTED),
|
||||||
|
('재시도 3회 · 백오프', 9, False, MUTED), ('동시성 2', 9, False, MUTED)], space=1)
|
||||||
|
box(sl, xs[2], r1, BW, BH,
|
||||||
|
[('생성 워커', 11.5, True, INK), ('OpenAI · gpt-4.1-mini', 9, False, MUTED),
|
||||||
|
('Structured Outputs', 9, False, MUTED), ('임베딩 배치 1회', 9, False, MUTED)], space=1)
|
||||||
|
box(sl, xs[3], r1, BW, BH,
|
||||||
|
[('중복제거 4단계', 11.5, True, WARN), ('해시 → trigram → 벡터', 9, False, WARN),
|
||||||
|
('미일치만 신규 등록', 9, False, WARN), ('나머지는 alias 흡수', 9, False, WARN)],
|
||||||
|
fill=WARN_BG, line=WARN, lw=1.4, space=1)
|
||||||
|
box(sl, xs[4], r1 - 0.14, 2.58, BH + 0.28,
|
||||||
|
[('PostgreSQL 16', 11.5, True, ACCENT), ('pgvector · ltree · pg_trgm', 9, False, ACCENT),
|
||||||
|
('keyword (전역 사전)', 9, False, ACCENT), ('merchant_keyword', 9, False, ACCENT),
|
||||||
|
('qa_pair · generation_run', 9, False, ACCENT)],
|
||||||
|
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
|
||||||
|
mid = r1 + BH / 2
|
||||||
|
for i in range(4):
|
||||||
|
a, b = xs[i] + BW, xs[i + 1]
|
||||||
|
arrow(sl, a + 0.04, mid, b - 0.04, mid)
|
||||||
|
for i, t in enumerate(['적재', 'job', '후보', 'write']):
|
||||||
|
label(sl, xs[i] + BW + 0.02, mid - 0.28, t, 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=0.42)
|
||||||
|
|
||||||
|
r2 = r1 + 2.05
|
||||||
|
box(sl, xs[3], r2, BW, BH,
|
||||||
|
[('Serving API', 11.5, True, INK), ('GET /v1/sites/:id/seo', 9, False, MUTED),
|
||||||
|
('GET /v1/sites/:id/aeo', 9, False, MUTED), ('읽기 99% · 캐시 대상', 9, False, MUTED)], space=1)
|
||||||
|
box(sl, xs[1], r2, BW, BH,
|
||||||
|
[('발행된 사이트', 11.5, True, INK), ('o2o-site-AEO', 9, False, MUTED),
|
||||||
|
('렌더링 시 호출', 9, False, MUTED)], space=1)
|
||||||
|
box(sl, xs[1], r2 + 1.5, 4.65, 0.6,
|
||||||
|
[('성과 수집 · Search Console · 서치어드바이저 · 유입 로그', 9.5, False, INK)],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, space=1)
|
||||||
|
|
||||||
|
elbow(sl, [(xs[4] + 1.29, r1 - 0.14 + BH + 0.28), (xs[4] + 1.29, r2 + BH / 2), (xs[3] + BW + 0.04, r2 + BH / 2)])
|
||||||
|
label(sl, xs[4] + 1.35, r2 - 0.05, '읽기', 8.5, w=0.6)
|
||||||
|
arrow(sl, xs[3] - 0.04, r2 + BH / 2, xs[1] + BW + 0.04, r2 + BH / 2)
|
||||||
|
label(sl, xs[1] + BW + 0.5, r2 + BH / 2 - 0.28, 'SEO / AEO payload', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.6)
|
||||||
|
arrow(sl, xs[1] + BW / 2, r2 + BH, xs[1] + BW / 2, r2 + 1.46)
|
||||||
|
label(sl, xs[1] + BW / 2 + 0.08, r2 + BH + 0.06, '노출 · 클릭', 8.5)
|
||||||
|
elbow(sl, [(xs[1], r2 + 1.8), (MX + 0.5, r2 + 1.8), (MX + 0.5, r1 + BH + 0.06)])
|
||||||
|
label(sl, MX + 0.58, r2 + 0.9, 'CTR < 0.2% → 강등', 8.5)
|
||||||
|
elbow(sl, [(xs[4] + 1.29, r1 - 0.18), (xs[4] + 1.29, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.04)],
|
||||||
|
color=ACCENT, dash=True)
|
||||||
|
label(sl, xs[2] + BW / 2, r1 - 0.78, '기존 키워드 주입 — 중복 후보 생성 자체를 억제', 9, ACCENT, MONO, PP_ALIGN.CENTER, w=4.6)
|
||||||
|
footer(sl, 3)
|
||||||
|
|
||||||
|
# ================================================================ 4 중복제거
|
||||||
|
sl, y = slide('중복제거 4단계', '값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어난다.', '핵심 메커니즘')
|
||||||
|
SX, SW, SH, GAP = 3.55, 3.5, 0.66, 0.19
|
||||||
|
stages = [
|
||||||
|
('0 · 금칙어 필터', '최고 · 1위 · 100% · 완치', '비용 0', STOP, STOP_BG, '차단 — 저장하지 않음', 'rejected_banned'),
|
||||||
|
('1 · normalized 완전 일치', 'NFKC · 소문자 · 공백/구두점 제거', 'B-tree 1회', WARN, WARN_BG, 'alias 흡수', '강남 뿌리 염색 → 강남 뿌리염색'),
|
||||||
|
('2 · pg_trgm 유사도 ≥ 0.6', '표기 변형 · 오타', 'GIN trgm', WARN, WARN_BG, 'alias 흡수', '강남 뿌리염색약 → 강남 뿌리염색 (0.67)'),
|
||||||
|
('3 · 코사인 유사도 ≥ 0.92', '의미 중복 — 후보 20건 안에서만', 'HNSW top-20', WARN, WARN_BG, 'alias 흡수', '강남 헤어샵 → 강남 미용실 (0.94)'),
|
||||||
|
]
|
||||||
|
top = y + 0.28
|
||||||
|
label(sl, SX, top - 0.30, 'LLM 후보 키워드', 11, INK, SANS, PP_ALIGN.CENTER, w=SW)
|
||||||
|
for i, (t, sub, cost, col, colbg, exit_t, exit_s) in enumerate(stages):
|
||||||
|
yy = top + i * (SH + GAP)
|
||||||
|
box(sl, SX, yy, SW, SH, [(t, 11, True, INK), (sub, 8.5, False, MUTED)], space=1)
|
||||||
|
label(sl, SX - 1.55, yy + 0.20, cost, 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
|
||||||
|
arrow(sl, SX + SW + 0.04, yy + SH / 2, SX + SW + 0.7, yy + SH / 2, color=col)
|
||||||
|
box(sl, SX + SW + 0.74, yy, 4.3, SH,
|
||||||
|
[(exit_t, 10, True, col), (exit_s, 8.5, False, col, MONO)],
|
||||||
|
fill=colbg, line=col, lw=1.1, space=1)
|
||||||
|
if i < len(stages) - 1:
|
||||||
|
arrow(sl, SX + SW / 2, yy + SH, SX + SW / 2, yy + SH + GAP - 0.02)
|
||||||
|
last = top + len(stages) * (SH + GAP)
|
||||||
|
arrow(sl, SX + SW / 2, last - GAP, SX + SW / 2, last - 0.02)
|
||||||
|
box(sl, SX, last, SW, SH,
|
||||||
|
[('4 · 새 키워드로 INSERT', 11, True, ACCENT), ('embedding 저장 · usage_count 1', 8.5, False, ACCENT)],
|
||||||
|
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
|
||||||
|
label(sl, SX - 1.55, last + 0.20, 'INSERT', 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
|
||||||
|
box(sl, SX + SW + 0.74, last, 4.3, SH,
|
||||||
|
[('어느 경로든 업체에는 연결된다', 10, True, INK), ('merchant_keyword · relevance · status', 8.5, False, MUTED, MONO)],
|
||||||
|
fill=None, line=INK, lw=1.0, space=1)
|
||||||
|
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
|
||||||
|
[('1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다 — 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지된다.',
|
||||||
|
10, False, MUTED)])
|
||||||
|
footer(sl, 4)
|
||||||
|
|
||||||
|
# ================================================================ 5 데이터 모델
|
||||||
|
sl, y = slide('데이터 모델', '키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거가 성립하지 않는다.', '스키마')
|
||||||
|
c1, c2, c3 = MX, MX + 4.7, MX + 8.9
|
||||||
|
box(sl, c1, y + 0.15, 2.6, 0.62, [('industry', 10.5, True, INK, MONO), ('path ltree · beauty.hair', 8.5, False, MUTED, MONO)], space=1)
|
||||||
|
box(sl, c1, y + 0.97, 2.6, 0.62, [('region', 10.5, True, INK, MONO), ('path ltree · kr.jeonbuk.gunsan', 8.5, False, MUTED, MONO)], space=1)
|
||||||
|
box(sl, c1, y + 2.35, 2.6, 1.15,
|
||||||
|
[('merchant', 10.5, True, INK, MONO), ('external_id ← 사이트 ID', 8.5, False, MUTED, MONO),
|
||||||
|
('description · profile jsonb', 8.5, False, MUTED, MONO), ('last_generated_at', 8.5, False, MUTED, MONO)], space=1)
|
||||||
|
box(sl, c2, y + 2.35, 3.1, 1.15,
|
||||||
|
[('merchant_keyword', 10.5, True, INK, MONO), ('relevance · status · source', 8.5, False, MUTED, MONO),
|
||||||
|
('impressions · clicks · ctr', 8.5, False, MUTED, MONO), ('PK (merchant_id, keyword_id)', 8.5, False, MUTED, MONO)], space=1)
|
||||||
|
box(sl, c3, y + 0.15, 3.6, 1.95,
|
||||||
|
[('keyword — 전역 사전', 10.5, True, ACCENT, MONO), ('canonical · 표시용', 8.5, False, ACCENT, MONO),
|
||||||
|
('normalized UNIQUE · 판정용', 8.5, False, ACCENT, MONO), ('aliases text[] · 흡수된 표기', 8.5, False, ACCENT, MONO),
|
||||||
|
('embedding vector(1536) HNSW', 8.5, False, ACCENT, MONO), ('intent · locale · usage_count', 8.5, False, ACCENT, MONO)],
|
||||||
|
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
|
||||||
|
box(sl, c3, y + 2.55, 3.6, 0.95,
|
||||||
|
[('qa_pair', 10.5, True, INK, MONO), ('question · answer', 8.5, False, MUTED, MONO),
|
||||||
|
('normalized_question UNIQUE', 8.5, False, MUTED, MONO), ('embedding vector(1536)', 8.5, False, MUTED, MONO)], space=1)
|
||||||
|
arrow(sl, c1 + 2.64, y + 0.46, c3 - 0.04, y + 0.55)
|
||||||
|
label(sl, c1 + 3.0, y + 0.18, '업종 분류', 8.5)
|
||||||
|
arrow(sl, c1 + 2.64, y + 1.28, c3 - 0.04, y + 1.20)
|
||||||
|
label(sl, c1 + 3.0, y + 1.32, '지역 분류', 8.5)
|
||||||
|
arrow(sl, c1 + 2.64, y + 2.92, c2 - 0.04, y + 2.92)
|
||||||
|
label(sl, c1 + 2.75, y + 2.62, '1 : N', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.9)
|
||||||
|
elbow(sl, [(c2 + 3.14, y + 2.75), (c3 - 0.35, y + 2.75), (c3 - 0.35, y + 1.1), (c3 - 0.04, y + 1.1)])
|
||||||
|
label(sl, c3 - 0.95, y + 1.85, 'N : 1', 8.5)
|
||||||
|
elbow(sl, [(c1 + 1.3, y + 3.54), (c1 + 1.3, y + 3.95), (c3 + 1.8, y + 3.95), (c3 + 1.8, y + 3.54)])
|
||||||
|
label(sl, c2 + 1.3, y + 3.62, '1 : N', 8.5)
|
||||||
|
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
|
||||||
|
[('강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있다.',
|
||||||
|
10, False, MUTED)])
|
||||||
|
footer(sl, 5)
|
||||||
|
|
||||||
|
# ================================================================ 6 API
|
||||||
|
sl, y = slide('API', ':id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다 — 연동 쪽에 ID 매핑 테이블이 필요 없다.', '연동 표면')
|
||||||
|
table(sl, MX, y, W - 2 * MX,
|
||||||
|
['메서드', '경로', '용도'],
|
||||||
|
[['`GET', '`/health', '헬스체크 · 현재 LLM provider 확인'],
|
||||||
|
['`POST', '`/v1/merchants/publish', '사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재 (sync:true 면 동기)'],
|
||||||
|
['`POST', '`/v1/merchants/:id/generate', '수동 재생성. ?sync=true&count=N'],
|
||||||
|
['`GET', '`/v1/sites/:id/seo', '발행 사이트가 렌더링 시 호출. title · description · keywords · tags'],
|
||||||
|
['`GET', '`/v1/sites/:id/aeo', '답변엔진용 topics · FAQ · structuredDataHints'],
|
||||||
|
['`POST', '`/v1/keywords/search', '어드민 — 자연어 질의로 키워드 사전 벡터 검색'],
|
||||||
|
['`POST', '`/v1/sites/:id/performance', '노출·클릭 주입 → CTR 갱신 → 저성과 강등']],
|
||||||
|
widths=[1.0, 3.5, 7.333], rowh=0.4)
|
||||||
|
box(sl, MX, y + 3.35, 5.75, 1.85,
|
||||||
|
[('SEO 응답', 10, True, ACCENT, MONO),
|
||||||
|
('{', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "title": "스테이머뭄 | 군산 펜션",', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "keywords": ["스테이머뭄", "군산 펜션", …],', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "tags": [{ "keyword": "군산 애견동반 펜션",', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "relevance": 0.88, "aliases": [ … ] }]', 9.5, False, INK_SOFT, MONO),
|
||||||
|
('}', 9.5, False, INK_SOFT, MONO)],
|
||||||
|
fill=SURFACE, space=1)
|
||||||
|
box(sl, MX + 6.05, y + 3.35, 5.78, 1.85,
|
||||||
|
[('AEO 응답 — 답변엔진이 인용하는 쪽', 10, True, ACCENT, MONO),
|
||||||
|
('{', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "topics": ["군산 펜션", "군산 커플 펜션"],', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "faqs": [{ "question": "…근처에 가볼 만한 곳은?",', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "answer": "선유도, 은파호수공원 …" }],', 9.5, False, INK_SOFT, MONO),
|
||||||
|
(' "structuredDataHints": { "type": "LocalBusiness" }', 9.5, False, INK_SOFT, MONO),
|
||||||
|
('}', 9.5, False, INK_SOFT, MONO)],
|
||||||
|
fill=SURFACE, space=1)
|
||||||
|
footer(sl, 6)
|
||||||
|
|
||||||
|
# ================================================================ 7 기술 선택
|
||||||
|
sl, y = slide('기술 선택', None, '스택')
|
||||||
|
table(sl, MX, y, W - 2 * MX,
|
||||||
|
['레이어', '선택', '이유'],
|
||||||
|
[['런타임', '`NestJS · TypeScript', 'o2o-site-AEO 와 payload 타입을 공유할 수 있다'],
|
||||||
|
['DB', '`PostgreSQL 16 + pgvector + ltree + pg_trgm', '정확 · 의미 · 계층 조회 3-in-1'],
|
||||||
|
['DB 접근', '`postgres.js (raw SQL)', '벡터 연산자와 ltree 는 어차피 raw SQL — ORM 을 얹으면 우회 코드가 더 는다'],
|
||||||
|
['큐 · 스케줄', '`BullMQ + Redis', '60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장'],
|
||||||
|
['LLM', '`OpenAI Structured Outputs / text-embedding-3-small', 'JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다'],
|
||||||
|
['관측', '`generation_run 테이블', '프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다']],
|
||||||
|
widths=[1.5, 4.6, 5.733], rowh=0.44)
|
||||||
|
box(sl, MX, y + 3.5, W - 2 * MX, 1.5,
|
||||||
|
[('로컬 실행', 10, True, ACCENT, MONO),
|
||||||
|
('npm install && cp .env.example .env # 기본 LLM_PROVIDER=mock — API 키 불필요', 10, False, INK_SOFT, MONO),
|
||||||
|
('npm run db:up && npm run db:migrate && npm run db:seed', 10, False, INK_SOFT, MONO),
|
||||||
|
('npm start # http://localhost:3100', 10, False, INK_SOFT, MONO),
|
||||||
|
('npm run smoke # 다른 터미널 — 엔드투엔드 점검', 10, False, INK_SOFT, MONO)],
|
||||||
|
fill=SURFACE, space=2)
|
||||||
|
footer(sl, 7)
|
||||||
|
|
||||||
|
# ================================================================ 8 검증 1
|
||||||
|
sl, y = slide('로컬 검증 — 같은 지역·업종 3곳', '강남/미용실 업체를 순서대로 발행했을 때 중복제거가 실제로 어떻게 걸리는지.', '검증 1')
|
||||||
|
table(sl, MX, y, 7.4,
|
||||||
|
['순서', '업체', '후보', '신규', '중복 (정확/표기/의미)'],
|
||||||
|
[['1', '레브살롱', '19', '19', '0 / 0 / 0'],
|
||||||
|
['2', '헤어랩 강남점', '19', '4', '15 / 0 / 0'],
|
||||||
|
['3', '강남 뷰티랩', '16', '3', '12 / 1 / 0']],
|
||||||
|
widths=[0.7, 2.4, 1.0, 1.0, 2.3], rowh=0.42)
|
||||||
|
box(sl, MX, y + 2.1, 7.4, 1.5,
|
||||||
|
[('matched_exact 강남 뿌리 염색 (sim=1.000 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
|
||||||
|
('matched_trigram 강남 뿌리염색약 (sim=0.667 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
|
||||||
|
('matched_exact 강남미용실추천 (sim=1.000 → \'강남 미용실 추천\')', 10, False, INK_SOFT, MONO)],
|
||||||
|
fill=SURFACE, space=3)
|
||||||
|
box(sl, MX + 7.8, y, 4.03, 3.6,
|
||||||
|
[('세 번째 업체에서는', 11, False, MUTED),
|
||||||
|
('16개 중 3개만', 22, True, ACCENT),
|
||||||
|
('새 키워드였다', 11, False, MUTED),
|
||||||
|
('', 8, False, MUTED),
|
||||||
|
('나머지 13개는 이미 사전에 있던', 10.5, False, INK_SOFT),
|
||||||
|
('키워드에 흡수됐다. 업체가 늘어도', 10.5, False, INK_SOFT),
|
||||||
|
('사전은 선형으로 늘지 않는다.', 10.5, False, INK_SOFT)],
|
||||||
|
fill=SURFACE, pad=0.24, space=4)
|
||||||
|
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
|
||||||
|
[('참고 — LLM_PROVIDER=mock 기준. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. 의미 중복은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.',
|
||||||
|
9.5, False, MUTED)])
|
||||||
|
footer(sl, 8)
|
||||||
|
|
||||||
|
# ================================================================ 9 검증 2
|
||||||
|
sl, y = slide('로컬 검증 — 군산 스테이머뭄 200개', '펜션 한 곳으로 키워드 200개를 뽑아 pgvector 에 적재했을 때 실제로 무엇이 쌓이는가.', '검증 2')
|
||||||
|
box(sl, MX, y, 5.6, 0.95,
|
||||||
|
[('후보 200 → 신규 174 / 중복(표기 26) / 연결 174 / QA 5 799ms', 10.5, False, INK_SOFT, MONO),
|
||||||
|
('keyword 174행 · 임베딩 174건 · 흡수된 표기 26개', 10.5, False, ACCENT, MONO)],
|
||||||
|
fill=SURFACE, space=3)
|
||||||
|
textbox(sl, MX, y + 1.25, 5.6, 0.3, [('relevance 분포', 11.5, True, INK)])
|
||||||
|
dist = [('0.93~0.99', 4, '브랜드 · 핵심', ACCENT),
|
||||||
|
('0.80~0.88', 32, '지역 × 업종 × 동반자', ACCENT),
|
||||||
|
('0.72~0.76', 11, '시즌', INK_SOFT),
|
||||||
|
('0.60~0.70', 51, '시설 · 서비스', INK_SOFT),
|
||||||
|
('0.50', 6, '질문형', MUTED),
|
||||||
|
('0.42', 40, '동반자 × 시설', STOP),
|
||||||
|
('0.38', 30, '동반자 × 서비스', STOP)]
|
||||||
|
by = y + 1.62
|
||||||
|
for i, (rng, n, note, col) in enumerate(dist):
|
||||||
|
yy = by + i * 0.36
|
||||||
|
label(sl, MX, yy + 0.03, rng, 9, MUTED, MONO, PP_ALIGN.RIGHT, w=0.95)
|
||||||
|
box(sl, MX + 1.05, yy, max(0.06, n * 0.048), 0.24, fill=col, line=None, rounded=False)
|
||||||
|
label(sl, MX + 1.05 + max(0.06, n * 0.048) + 0.1, yy + 0.03, f'{n} {note}', 9, col, SANS, w=3.2)
|
||||||
|
box(sl, MX + 7.4, y, 4.43, 2.05,
|
||||||
|
[('하위 70개는 이런 것들', 11, True, STOP),
|
||||||
|
('애견동반 바베큐장 0.38', 10, False, INK_SOFT, MONO),
|
||||||
|
('태교여행 프라이빗 스파 0.38', 10, False, INK_SOFT, MONO),
|
||||||
|
('커플 바베큐장 0.38', 10, False, INK_SOFT, MONO),
|
||||||
|
('', 6, False, MUTED),
|
||||||
|
('문법은 맞지만 아무도 이렇게 검색하지 않는다.', 10, False, MUTED)],
|
||||||
|
fill=STOP_BG, line=STOP, lw=1.1, space=2)
|
||||||
|
box(sl, MX + 7.4, y + 2.35, 4.43, 1.9,
|
||||||
|
[('잘 작동한 부분 — 벡터 검색', 11, True, ACCENT),
|
||||||
|
('"선유도 근처 바베큐 되는 펜션"', 10, False, INK_SOFT, MONO),
|
||||||
|
(' 0.686 선유도 근처 펜션', 10, False, ACCENT, MONO),
|
||||||
|
(' 0.439 고군산군도 근처 펜션', 10, False, ACCENT, MONO),
|
||||||
|
(' 0.392 선유도 펜션 추천', 10, False, ACCENT, MONO)],
|
||||||
|
fill=SURFACE, space=2)
|
||||||
|
footer(sl, 9)
|
||||||
|
|
||||||
|
# ================================================================ 10 발견
|
||||||
|
sl, y = slide('발견 — 저장한 것의 89%는 쓰이지 않는다', None, '문제 정의')
|
||||||
|
box(sl, MX, y, 5.3, 2.5,
|
||||||
|
[('적재된 키워드 174개 중', 12, False, MUTED),
|
||||||
|
('89%', 62, True, STOP),
|
||||||
|
('가 한 번도 서빙되지 않는다 (서빙 20개 / 사장 154개)', 11.5, False, INK_SOFT)],
|
||||||
|
fill=STOP_BG, line=None, pad=0.3, space=6, anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
box(sl, MX + 5.7, y, 6.13, 2.5,
|
||||||
|
[('그런데 이 154개는', 12, True, INK),
|
||||||
|
('· 매번 dedup 후보 검색 대상이고', 11.5, False, INK_SOFT),
|
||||||
|
('· HNSW 인덱스에 들어가 있고', 11.5, False, INK_SOFT),
|
||||||
|
('· 다음 생성 때 프롬프트에도 실린다', 11.5, False, INK_SOFT),
|
||||||
|
('', 6, False, MUTED),
|
||||||
|
('순수한 부채다. 주기 생성을 30일마다 돌리면 매달 반복된다.', 11.5, True, STOP)],
|
||||||
|
fill=SURFACE, pad=0.3, space=5)
|
||||||
|
textbox(sl, MX, y + 2.85, W - 2 * MX, 0.3, [('시간이 지나면 실제로 바뀌는 건 3가지뿐', 14, True, INK)])
|
||||||
|
table(sl, MX, y + 3.3, W - 2 * MX,
|
||||||
|
['무엇이 바뀌나', '올바른 대응', '현행 설계'],
|
||||||
|
[['업체 정보 (메뉴 추가, 이전, 서비스 변경)', '이벤트 기반 재생성', '30일 크론이 대신 처리'],
|
||||||
|
['성과 데이터 누적', '재순위 — 생성이 아님', '재생성으로 오해'],
|
||||||
|
['계절 · 트렌드 (연말 파티헤어, 여름 네일)', '업종 단위 생성 — 업체 수와 무관', '업체마다 중복 생성']],
|
||||||
|
widths=[4.6, 4.0, 3.233], rowh=0.4)
|
||||||
|
footer(sl, 10)
|
||||||
|
|
||||||
|
# ================================================================ 11 개선
|
||||||
|
sl, y = slide('개선 방향', '키워드는 업체 수 × 시간이 아니라 업체 수에만 비례해야 한다.', '다음 단계')
|
||||||
|
items = [
|
||||||
|
('relevance 컷', '0.6 미만 후보는 저장하지 않는다', '200개 → 110개. 저장조차 하지 말아야 할 것들.'),
|
||||||
|
('업체당 정원제', 'active 슬롯 30개 고정', '새 후보는 최약체와 경쟁해서 이겨야 들어온다.\n시스템이 스스로 상한을 갖는다.'),
|
||||||
|
('profile_hash', '업체 정보가 바뀔 때만 재생성', '정보가 그대로면 재생성해서 얻을 게 없다. 주기 크론이 사실상 무력화된다.'),
|
||||||
|
('크론 성격 전환', '생성 → 정리', '고아 키워드 삭제, 저성과 강등. 늘리는 일이 아니라 줄이는 일.'),
|
||||||
|
]
|
||||||
|
for i, (t, s, d) in enumerate(items):
|
||||||
|
yy = y + i * 0.85
|
||||||
|
box(sl, MX, yy, 0.42, 0.68, [(str(i + 1), 12, True, ACCENT, MONO)],
|
||||||
|
fill=SURFACE, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER, pad=0.02)
|
||||||
|
textbox(sl, MX + 0.62, yy + 0.02, 2.5, 0.3, [(t, 13, True, INK)])
|
||||||
|
textbox(sl, MX + 3.2, yy + 0.04, 3.0, 0.3, [(s, 11, False, ACCENT, MONO)])
|
||||||
|
textbox(sl, MX + 6.4, yy + 0.02, 5.4, 0.6,
|
||||||
|
[(ln, 10.5, False, MUTED) for ln in d.split('\n')], space=1)
|
||||||
|
box(sl, MX, y + 3.7, W - 2 * MX, 1.5,
|
||||||
|
[('그 뒤에 남은 작업', 11, True, ACCENT),
|
||||||
|
('JSON-LD 조립 (structuredDataHints → LocalBusiness / FAQPage / Service) · /llms.txt 서빙', 11, False, INK_SOFT),
|
||||||
|
('업종 ltree 상위 노드 키워드 상속 · Redis 응답 캐시 · Search Console API 직접 연동 · 키워드 승인/차단 어드민', 11, False, INK_SOFT)],
|
||||||
|
fill=ACC_BG, line=None, pad=0.26, space=4)
|
||||||
|
footer(sl, 11)
|
||||||
|
|
||||||
|
prs.save('docs/architecture.pptx')
|
||||||
|
print('✅ docs/architecture.pptx')
|
||||||
160
ontology/scripts/build-nationwide-dataset.mjs
Normal file
160
ontology/scripts/build-nationwide-dataset.mjs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
|
||||||
|
* node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json
|
||||||
|
*
|
||||||
|
* 설계 원칙
|
||||||
|
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고
|
||||||
|
* 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용).
|
||||||
|
* · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다.
|
||||||
|
* 산간 지역에 '오션뷰 펜션'을 만들지 않는다.
|
||||||
|
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
|
||||||
|
|
||||||
|
// ── 공통 어휘
|
||||||
|
const STAY = ['펜션', '숙소', '독채펜션', '풀빌라', '스파펜션', '애견펜션', '감성펜션', '글램핑', '독채'];
|
||||||
|
const INTENT = { 추천:'local', 예약:'transactional', 가격:'transactional', 후기:'informational',
|
||||||
|
순위:'informational', 저렴한곳:'local', 가성비:'local', 실시간예약:'transactional',
|
||||||
|
당일예약:'transactional', 특가:'transactional' };
|
||||||
|
const WITH = ['커플', '가족', '친구', '애견동반', '단체', '아이동반', '부모님', '4인', '6인', '2인'];
|
||||||
|
const VIBE = ['감성', '조용한', '분위기 좋은', '사진찍기 좋은', '인생샷', '깔끔한', '신축'];
|
||||||
|
const TRAVEL = ['1박2일', '2박3일', '주말여행', '뚜벅이 여행', '워케이션'];
|
||||||
|
|
||||||
|
// 지역 성격별 유효 시설 — 여기가 조합 폭발을 막는 장치다
|
||||||
|
const FEATURES_BY_TYPE = {
|
||||||
|
해변: ['오션뷰', '바다뷰', '노을뷰', '일출뷰', '해변 근처', '바다 보이는'],
|
||||||
|
섬: ['오션뷰', '바다뷰', '배타고 가는', '섬'],
|
||||||
|
산간: ['산뷰', '숲속', '불멍', '화로대', '벽난로', '단풍'],
|
||||||
|
계곡: ['계곡', '물놀이', '계곡뷰', '불멍'],
|
||||||
|
호수: ['호수뷰', '레이크뷰', '물놀이', '노을뷰'],
|
||||||
|
강변: ['강뷰', '리버뷰', '노을뷰'],
|
||||||
|
도심: ['역세권', '시내', '주차', '도보 여행'],
|
||||||
|
습지: ['자연', '산책'],
|
||||||
|
};
|
||||||
|
const FEATURES_COMMON = ['바베큐', '스파', '자쿠지', '수영장', '독채', '프라이빗', '복층', '테라스', '애견운동장', '넷플릭스'];
|
||||||
|
|
||||||
|
const SEASON_BY_TYPE = {
|
||||||
|
해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'],
|
||||||
|
섬: ['여름휴가', '일출'],
|
||||||
|
산간: ['겨울', '단풍', '눈꽃'],
|
||||||
|
계곡: ['여름휴가', '물놀이', '단풍'],
|
||||||
|
호수: ['여름휴가', '단풍', '벚꽃'],
|
||||||
|
강변: ['벚꽃', '단풍'],
|
||||||
|
도심: ['벚꽃', '연말'],
|
||||||
|
습지: ['가을', '갈대'],
|
||||||
|
};
|
||||||
|
const SEASON_COMMON = ['겨울', '연말', '크리스마스', '주말', '성수기'];
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const norm = (s) => s.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
|
||||||
|
|
||||||
|
function add(region, keyword, { intent = 'local', kind = 'keyword', category, tier, relevance }) {
|
||||||
|
const k = keyword.replace(/\s+/g, ' ').trim();
|
||||||
|
const id = `${region.key}|${norm(k)}`;
|
||||||
|
if (!k || seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
rows.push({
|
||||||
|
sido: region.sido, region: region.name, regionKey: region.key,
|
||||||
|
regionType: region.type.join('·'),
|
||||||
|
keyword: k, kind, intent, category, tier,
|
||||||
|
relevance: Math.round(relevance * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniq = (a) => [...new Set(a)];
|
||||||
|
|
||||||
|
for (const r of regions) {
|
||||||
|
const R = r.name;
|
||||||
|
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
|
||||||
|
// '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다.
|
||||||
|
const seasons = uniq([
|
||||||
|
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
|
||||||
|
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
|
||||||
|
...SEASON_COMMON,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// T1 코어 — 주력 후보.
|
||||||
|
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
|
||||||
|
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
|
||||||
|
const names = [R, ...(r.aliases ?? [])];
|
||||||
|
for (const N of names) {
|
||||||
|
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
|
||||||
|
add(r, `${N} 숙소`, { category: '코어', tier: '주력', relevance: N === R ? 0.96 : 0.92 });
|
||||||
|
for (const s of STAY.slice(2)) add(r, `${N} ${s}`, { category: '코어', tier: '주력', relevance: 0.9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T2 의도 — 보조
|
||||||
|
for (const N of names)
|
||||||
|
for (const [m, it] of Object.entries(INTENT))
|
||||||
|
add(r, `${N} 펜션 ${m}`, { intent: it, category: '의도', tier: '보조', relevance: N === R ? 0.88 : 0.84 });
|
||||||
|
for (const s of ['독채펜션', '풀빌라', '애견펜션', '감성펜션'])
|
||||||
|
for (const m of ['추천', '예약', '가격', '후기'])
|
||||||
|
add(r, `${R} ${s} ${m}`, { intent: INTENT[m], category: '의도', tier: '보조', relevance: 0.8 });
|
||||||
|
|
||||||
|
// T3 동반자
|
||||||
|
for (const w of WITH) {
|
||||||
|
add(r, `${R} ${w} 펜션`, { category: '동반자', tier: '보조', relevance: 0.85 });
|
||||||
|
add(r, `${R} ${w} 펜션 추천`, { category: '동반자', tier: '롱테일', relevance: 0.7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T4 시설 — 지역 성격에 맞는 것만
|
||||||
|
for (const f of feats) {
|
||||||
|
add(r, `${R} ${f} 펜션`, { category: '시설', tier: '보조', relevance: 0.83 });
|
||||||
|
}
|
||||||
|
for (const f of feats.slice(0, 6))
|
||||||
|
add(r, `${R} 커플 ${f} 펜션`, { category: '시설', tier: '롱테일', relevance: 0.55 });
|
||||||
|
|
||||||
|
// T5 시즌
|
||||||
|
for (const s of seasons) add(r, `${R} ${s} 펜션`, { category: '시즌', tier: '보조', relevance: 0.76 });
|
||||||
|
|
||||||
|
// T6 관광지 앵커 — 지역 고유
|
||||||
|
for (const sp of r.spots) {
|
||||||
|
add(r, `${sp} 근처 펜션`, { category: '관광지', tier: '보조', relevance: 0.84 });
|
||||||
|
add(r, `${sp} 근처 숙소`, { category: '관광지', tier: '보조', relevance: 0.81 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// T7 분위기·여행형태
|
||||||
|
for (const v of VIBE) add(r, `${R} ${v} 숙소`, { category: '분위기', tier: '롱테일', relevance: 0.68 });
|
||||||
|
for (const t of TRAVEL) add(r, `${R} ${t} 숙소`, { category: '여행형태', tier: '롱테일', relevance: 0.66 });
|
||||||
|
|
||||||
|
// T8 질문형 (AEO)
|
||||||
|
const qs = [
|
||||||
|
[`${R} 펜션 어디가 좋아요`, 'informational', 0.72],
|
||||||
|
[`${R} 펜션 1박 얼마인가요`, 'transactional', 0.7],
|
||||||
|
[`${R} 애견동반 펜션 있나요`, 'informational', 0.68],
|
||||||
|
[`${R} 펜션 바베큐 가능한가요`, 'informational', 0.67],
|
||||||
|
[`${R} 여행 몇박이 좋을까요`, 'informational', 0.6],
|
||||||
|
[`${R} 펜션 성수기 언제인가요`, 'informational', 0.58],
|
||||||
|
];
|
||||||
|
for (const [q, it, rel] of qs) add(r, q, { intent: it, category: '질문형', tier: '롱테일', relevance: rel });
|
||||||
|
|
||||||
|
// T9 태그 (칩 UI)
|
||||||
|
for (const t of uniq([...feats.slice(0, 8), ...WITH.slice(0, 5), ...VIBE.slice(0, 4)]))
|
||||||
|
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
|
||||||
|
const sidoKey = {};
|
||||||
|
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
|
||||||
|
const sidoList = uniq(regions.map((x) => x.sido));
|
||||||
|
for (const sido of sidoList) {
|
||||||
|
const pseudo = { sido, name: sido, key: sidoKey[sido], type: [] };
|
||||||
|
for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션'])
|
||||||
|
add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 });
|
||||||
|
for (const m of ['추천', '예약', '가격', '후기'])
|
||||||
|
add(pseudo, `${sido} 펜션 ${m}`, { intent: INTENT[m], category: '광역', tier: '보조', relevance: 0.85 });
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync('data/nationwide-pension-keywords.json',
|
||||||
|
JSON.stringify({ topic: '전국 지역별 펜션', locale: 'ko-KR',
|
||||||
|
generatedBy: 'region master × search-pattern expansion (region-type aware)',
|
||||||
|
regionCount: regions.length, count: rows.length, items: rows }, null, 2) + '\n');
|
||||||
|
|
||||||
|
const by = (f) => rows.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
|
||||||
|
console.log(`✅ data/nationwide-pension-keywords.json ${rows.length}건 / ${regions.length}개 지역`);
|
||||||
|
console.log(` 지역당 평균 ${Math.round(rows.length / (regions.length + sidoList.length))}건`);
|
||||||
|
console.log(' 티어:', by('tier'));
|
||||||
|
console.log(' 카테고리:', by('category'));
|
||||||
10
ontology/scripts/db-dump.sh
Executable file
10
ontology/scripts/db-dump.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 임베딩 포함 전체 덤프 — 배포 대상에서 재임베딩 없이 그대로 복원된다.
|
||||||
|
set -euo pipefail
|
||||||
|
OUT="${1:-data/ontology-dump.sql.gz}"
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
docker exec -i ontology-postgres pg_dump -U ontology -d ontology \
|
||||||
|
--no-owner --no-privileges --clean --if-exists | gzip -9 > "$OUT"
|
||||||
|
echo "✅ $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
|
echo " 복원: gunzip -c $OUT | psql \"\$TARGET_DATABASE_URL\""
|
||||||
|
echo " (대상 DB 에 vector · ltree · pg_trgm 확장이 설치돼 있어야 한다)"
|
||||||
157
ontology/scripts/export-db-xlsx.py
Normal file
157
ontology/scripts/export-db-xlsx.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용).
|
||||||
|
python3 scripts/export-db-xlsx.py
|
||||||
|
데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 —
|
||||||
|
384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다."""
|
||||||
|
import csv, io, subprocess, collections
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
OUT = 'data/배포용_키워드_DB덤프.xlsx'
|
||||||
|
CONT = 'ontology-postgres'
|
||||||
|
DB = ['psql', '-U', 'ontology', '-d', 'ontology', '-t', '-A', '--csv', '-c']
|
||||||
|
|
||||||
|
INK = '1F2A2B'
|
||||||
|
HEAD = PatternFill('solid', fgColor='0D6A60')
|
||||||
|
THIN = Side(style='thin', color='D5DCDB')
|
||||||
|
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||||
|
SRC_FILL = {'dataset': 'DFF0EC', 'nationwide': 'FFFFFF', 'manual': 'F6EAD2'}
|
||||||
|
|
||||||
|
|
||||||
|
def query(sql: str):
|
||||||
|
out = subprocess.run(['docker', 'exec', '-i', CONT, *DB, sql],
|
||||||
|
capture_output=True, text=True, check=True).stdout
|
||||||
|
return list(csv.reader(io.StringIO(out)))
|
||||||
|
|
||||||
|
|
||||||
|
def sheet(wb, title, header, rows, widths_, fill_col=None, first=False):
|
||||||
|
ws = wb.active if first else wb.create_sheet(title)
|
||||||
|
if first: ws.title = title
|
||||||
|
ws.append(header)
|
||||||
|
for r in rows: ws.append(r)
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)):
|
||||||
|
fill = None
|
||||||
|
if fill_col is not None:
|
||||||
|
fill = PatternFill('solid', fgColor=SRC_FILL.get(row[fill_col].value, 'FFFFFF'))
|
||||||
|
for c in row:
|
||||||
|
c.font = Font(size=10, color=INK); c.border = BOX
|
||||||
|
if fill: c.fill = fill
|
||||||
|
for c in range(1, len(header) + 1):
|
||||||
|
cell = ws.cell(row=1, column=c)
|
||||||
|
cell.fill = HEAD; cell.font = Font(bold=True, color='FFFFFF', size=10)
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
cell.border = BOX
|
||||||
|
ws.row_dimensions[1].height = 22
|
||||||
|
ws.freeze_panes = 'A2'
|
||||||
|
ws.auto_filter.ref = f'A1:{get_column_letter(len(header))}{ws.max_row}'
|
||||||
|
for i, w in enumerate(widths_, start=1):
|
||||||
|
ws.column_dimensions[get_column_letter(i)].width = w
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
# 1) 키워드 — DB 전체
|
||||||
|
kw = query("""
|
||||||
|
SELECT k.id, k.canonical, k.normalized, k.locale,
|
||||||
|
array_to_string(k.aliases, ' | ') AS aliases,
|
||||||
|
k.intent, k.kind, COALESCE(k.category,'') AS category, k.source,
|
||||||
|
COALESCE(k.industry_id,'') , COALESCE(k.region_id,''),
|
||||||
|
COALESCE(r.name,''), COALESCE(sido.name,''),
|
||||||
|
k.usage_count,
|
||||||
|
(k.embedding IS NOT NULL) AS has_embedding,
|
||||||
|
to_char(k.updated_at,'YYYY-MM-DD HH24:MI')
|
||||||
|
FROM keyword k
|
||||||
|
LEFT JOIN region r ON r.id = k.region_id
|
||||||
|
LEFT JOIN region sido ON sido.id = regexp_replace(k.region_id, '\\.[^.]+$', '')
|
||||||
|
ORDER BY k.source, sido.name NULLS FIRST, r.name NULLS FIRST, k.canonical
|
||||||
|
""")
|
||||||
|
sheet(wb, '키워드',
|
||||||
|
['id', '키워드', '정규화', 'locale', '흡수된 표기(alias)', '의도', '종류', '카테고리',
|
||||||
|
'출처', '업종ID', '지역ID', '지역', '시도', '사용업체수', '임베딩', '갱신일시'],
|
||||||
|
kw, [38, 30, 26, 8, 30, 14, 8, 10, 12, 14, 24, 12, 8, 10, 9, 17],
|
||||||
|
fill_col=8, first=True)
|
||||||
|
|
||||||
|
# 2~5) 마스터
|
||||||
|
sheet(wb, '지역', ['지역ID', '경로(ltree)', '지역명'],
|
||||||
|
query("SELECT id, path::text, name FROM region ORDER BY path"), [26, 26, 16])
|
||||||
|
sheet(wb, '업종', ['업종ID', '경로(ltree)', '업종명'],
|
||||||
|
query("SELECT id, path::text, name FROM industry ORDER BY path"), [22, 22, 16])
|
||||||
|
sheet(wb, '업체',
|
||||||
|
['외부ID', '상호', '업종', '지역', '소개', '사이트', '프로필(JSON)'],
|
||||||
|
query("""SELECT m.external_id, m.name, COALESCE(i.name,''), COALESCE(r.name,''),
|
||||||
|
m.description, COALESCE(m.site_url,''), m.profile::text
|
||||||
|
FROM merchant m
|
||||||
|
LEFT JOIN industry i ON i.id=m.industry_id
|
||||||
|
LEFT JOIN region r ON r.id=m.region_id
|
||||||
|
ORDER BY m.external_id"""),
|
||||||
|
[14, 18, 12, 10, 50, 34, 70])
|
||||||
|
sheet(wb, 'QA(AEO)', ['업체', '질문', '답변', '상태'],
|
||||||
|
query("""SELECT m.name, q.question, q.answer, q.status::text
|
||||||
|
FROM qa_pair q JOIN merchant m ON m.id=q.merchant_id
|
||||||
|
ORDER BY m.name, q.created_at"""), [16, 44, 70, 10])
|
||||||
|
|
||||||
|
# 5-b) 업체↔키워드 연결
|
||||||
|
sheet(wb, '업체키워드',
|
||||||
|
['업체', '키워드', '관련도', '상태', '출처', '노출수', '클릭수', 'CTR', '근거'],
|
||||||
|
query("""SELECT m.name, k.canonical, round(mk.relevance::numeric,2), mk.status::text,
|
||||||
|
mk.source, mk.impressions, mk.clicks, round(mk.ctr::numeric,4),
|
||||||
|
COALESCE(mk.rationale,'')
|
||||||
|
FROM merchant_keyword mk
|
||||||
|
JOIN merchant m ON m.id=mk.merchant_id
|
||||||
|
JOIN keyword k ON k.id=mk.keyword_id
|
||||||
|
ORDER BY m.name, mk.relevance DESC"""),
|
||||||
|
[16, 30, 9, 10, 10, 10, 9, 9, 28])
|
||||||
|
|
||||||
|
# 5-c) 생성 이력 (감사 로그)
|
||||||
|
sheet(wb, '생성이력',
|
||||||
|
['업체', 'provider', 'model', '프롬프트버전', '트리거', '상태', '통계', '시작', '종료'],
|
||||||
|
query("""SELECT COALESCE(m.name,''), g.provider, g.model, g.prompt_version,
|
||||||
|
g.trigger, g.status, g.stats::text,
|
||||||
|
to_char(g.started_at,'YYYY-MM-DD HH24:MI'),
|
||||||
|
COALESCE(to_char(g.finished_at,'YYYY-MM-DD HH24:MI'),'')
|
||||||
|
FROM generation_run g
|
||||||
|
LEFT JOIN merchant m ON m.id=g.merchant_id
|
||||||
|
ORDER BY g.started_at DESC"""),
|
||||||
|
[16, 10, 20, 14, 12, 10, 60, 17, 17])
|
||||||
|
|
||||||
|
# 6) 배포 가이드
|
||||||
|
counts = collections.Counter(r[8] for r in kw)
|
||||||
|
guide = [
|
||||||
|
('무엇이 들어있나', ''),
|
||||||
|
('', f"벡터 DB(keyword 테이블)에 실제 적재된 {len(kw):,}건 전부. 데이터셋 JSON 이 아니라 DB 가 기준이다."),
|
||||||
|
('', '출처별: ' + ' · '.join(f'{k} {v:,}' for k, v in counts.most_common())),
|
||||||
|
('', 'dataset = 군산 상세(매칭 엔진 개발용) / nationwide = 전국 54개 지역'),
|
||||||
|
('', 'DB 의 7개 테이블을 모두 담았다: keyword / region / industry / merchant /'),
|
||||||
|
('', 'merchant_keyword / qa_pair / generation_run.'),
|
||||||
|
('', ''),
|
||||||
|
('임베딩은 왜 없나', ''),
|
||||||
|
('', '384개 float × 7천 행이라 엑셀에 담을 수 없고 담아도 못 읽는다.'),
|
||||||
|
('', '[임베딩] 열은 DB 에 벡터가 있는지만 표시한다.'),
|
||||||
|
('', '같은 모델(Xenova/multilingual-e5-small)로 다시 만들면 동일한 값이 나오므로'),
|
||||||
|
('', '텍스트만 있으면 복원된다.'),
|
||||||
|
('', ''),
|
||||||
|
('배포 방법 2가지', ''),
|
||||||
|
('A. pg_dump (권장)', '임베딩 포함 그대로 복원. 재임베딩 불필요.'),
|
||||||
|
('', ' npm run db:dump → data/ontology-dump.sql.gz'),
|
||||||
|
('', ' gunzip -c data/ontology-dump.sql.gz | psql $TARGET_URL'),
|
||||||
|
('B. 재적재', '텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.'),
|
||||||
|
('', ' npm run db:migrate && npm run db:seed'),
|
||||||
|
('', ' npm run dataset:ingest && npm run dataset:ingest-nationwide'),
|
||||||
|
('', ''),
|
||||||
|
('⚠ 검색량은 아직 비어있다', ''),
|
||||||
|
('', '이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.'),
|
||||||
|
('', '네이버 검색광고 키워드도구로 월간검색수를 채우고 월 10 미만을 걷어내야'),
|
||||||
|
('', '실서비스에 쓸 수 있다. (npm run dataset:import-related 로 CSV 병합)'),
|
||||||
|
]
|
||||||
|
ws = sheet(wb, '배포가이드', ['항목', '내용'], guide, [22, 100])
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2):
|
||||||
|
if row[0].value and not row[1].value:
|
||||||
|
row[0].font = Font(size=10, bold=True, color='0D6A60')
|
||||||
|
row[1].alignment = Alignment(wrap_text=True, vertical='center')
|
||||||
|
|
||||||
|
wb.save(OUT)
|
||||||
|
print(f'✅ {OUT}')
|
||||||
|
print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets))
|
||||||
|
print(f' 키워드 {len(kw):,}행 (' + ', '.join(f'{k} {v:,}' for k, v in counts.most_common()) + ')')
|
||||||
155
ontology/scripts/export-xlsx.py
Normal file
155
ontology/scripts/export-xlsx.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""전국 펜션 키워드 데이터셋 → 엑셀.
|
||||||
|
python3 scripts/export-xlsx.py
|
||||||
|
검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리."""
|
||||||
|
import json, collections
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
SRC = 'data/nationwide-pension-keywords.json'
|
||||||
|
OUT = 'data/전국_펜션_SEO_AEO_키워드.xlsx'
|
||||||
|
|
||||||
|
INK = '1F2A2B'
|
||||||
|
ACC = '0D6A60'
|
||||||
|
HEAD = PatternFill('solid', fgColor='0D6A60')
|
||||||
|
BAND = PatternFill('solid', fgColor='F1F5F4')
|
||||||
|
TIER = {'주력': 'DFF0EC', '보조': 'FFFFFF', '롱테일': 'F7F7F5', '태그': 'F6EAD2'}
|
||||||
|
THIN = Side(style='thin', color='D5DCDB')
|
||||||
|
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||||
|
|
||||||
|
SLOT = {
|
||||||
|
'코어': '메인 페이지 (주력)', '광역': '광역 랜딩',
|
||||||
|
'의도': '메인 / 예약 페이지', '동반자': '객실 페이지',
|
||||||
|
'시설': '시설 페이지', '관광지': '주변 여행 페이지',
|
||||||
|
'시즌': '블로그 · 프로모션', '분위기': '블로그 · 소개',
|
||||||
|
'여행형태': '블로그 · 코스', '질문형': 'FAQ (AEO · FAQPage)',
|
||||||
|
'태그': '필터 UI (SEO 아님)',
|
||||||
|
}
|
||||||
|
|
||||||
|
data = json.load(open(SRC, encoding='utf-8'))
|
||||||
|
items = data['items']
|
||||||
|
regions = json.load(open('data/regions.json', encoding='utf-8'))['regions']
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
def style_header(ws, ncols, height=22):
|
||||||
|
for c in range(1, ncols + 1):
|
||||||
|
cell = ws.cell(row=1, column=c)
|
||||||
|
cell.fill = HEAD
|
||||||
|
cell.font = Font(bold=True, color='FFFFFF', size=10)
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
cell.border = BOX
|
||||||
|
ws.row_dimensions[1].height = height
|
||||||
|
ws.freeze_panes = 'A2'
|
||||||
|
ws.auto_filter.ref = f'A1:{get_column_letter(ncols)}{ws.max_row}'
|
||||||
|
|
||||||
|
def widths(ws, ws_widths):
|
||||||
|
for i, w in enumerate(ws_widths, start=1):
|
||||||
|
ws.column_dimensions[get_column_letter(i)].width = w
|
||||||
|
|
||||||
|
# ────────────────────────────────── 1. 키워드
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = '키워드'
|
||||||
|
cols = ['시도', '지역', '지역키', '지역성격', '키워드', '종류', '의도', '카테고리',
|
||||||
|
'티어', '관련도', '월간검색수', '경쟁도', '추천 배치', '비고']
|
||||||
|
ws.append(cols)
|
||||||
|
for it in items:
|
||||||
|
ws.append([
|
||||||
|
it['sido'], it['region'], it['regionKey'], it['regionType'],
|
||||||
|
it['keyword'], it['kind'], it['intent'], it['category'],
|
||||||
|
it['tier'], it['relevance'], None, None,
|
||||||
|
SLOT.get(it['category'], ''), None,
|
||||||
|
])
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(cols)):
|
||||||
|
fill = PatternFill('solid', fgColor=TIER.get(row[8].value, 'FFFFFF'))
|
||||||
|
for c in row:
|
||||||
|
c.font = Font(size=10, color=INK)
|
||||||
|
c.border = BOX
|
||||||
|
c.fill = fill
|
||||||
|
row[9].number_format = '0.00'
|
||||||
|
row[10].number_format = '#,##0'
|
||||||
|
row[4].font = Font(size=10, bold=True, color=INK)
|
||||||
|
style_header(ws, len(cols))
|
||||||
|
widths(ws, [8, 12, 24, 14, 30, 8, 14, 10, 9, 9, 12, 10, 22, 16])
|
||||||
|
|
||||||
|
# ────────────────────────────────── 2. 지역 마스터
|
||||||
|
ws = wb.create_sheet('지역마스터')
|
||||||
|
ws.append(['시도', '지역', '지역키', '지역성격', '대표 관광지 (앵커)', '키워드 수'])
|
||||||
|
cnt = collections.Counter(i['regionKey'] for i in items)
|
||||||
|
for r in regions:
|
||||||
|
ws.append([r['sido'], r['name'], r['key'], '·'.join(r['type']),
|
||||||
|
', '.join(r['spots']), cnt.get(r['key'], 0)])
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=6):
|
||||||
|
for c in row:
|
||||||
|
c.font = Font(size=10, color=INK); c.border = BOX
|
||||||
|
c.alignment = Alignment(vertical='center', wrap_text=(c.column == 5))
|
||||||
|
style_header(ws, 6)
|
||||||
|
widths(ws, [8, 14, 26, 14, 70, 10])
|
||||||
|
|
||||||
|
# ────────────────────────────────── 3. 지역별 요약
|
||||||
|
ws = wb.create_sheet('지역별요약')
|
||||||
|
tiers = ['주력', '보조', '롱테일', '태그']
|
||||||
|
ws.append(['시도', '지역'] + tiers + ['합계'])
|
||||||
|
per = collections.defaultdict(collections.Counter)
|
||||||
|
meta = {}
|
||||||
|
for i in items:
|
||||||
|
per[i['regionKey']][i['tier']] += 1
|
||||||
|
meta[i['regionKey']] = (i['sido'], i['region'])
|
||||||
|
for key, c in sorted(per.items(), key=lambda kv: (-sum(kv[1].values()))):
|
||||||
|
sido, name = meta[key]
|
||||||
|
ws.append([sido, name] + [c[t] for t in tiers] + [sum(c.values())])
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=7):
|
||||||
|
for c in row:
|
||||||
|
c.font = Font(size=10, color=INK); c.border = BOX
|
||||||
|
style_header(ws, 7)
|
||||||
|
widths(ws, [8, 14, 9, 9, 10, 9, 9])
|
||||||
|
|
||||||
|
# ────────────────────────────────── 4. 사용 가이드
|
||||||
|
ws = wb.create_sheet('사용가이드')
|
||||||
|
guide = [
|
||||||
|
('이 파일은 무엇인가', ''),
|
||||||
|
('', f"전국 {data['regionCount']}개 펜션 수요 지역 × 검색 패턴으로 만든 SEO/AEO 키워드 후보 {len(items):,}건."),
|
||||||
|
('', '지역 성격(해변·산간·호수·도심·섬)에 맞는 시설 키워드만 전개했다. 산간 지역에 오션뷰 키워드는 없다.'),
|
||||||
|
('', ''),
|
||||||
|
('⚠ 반드시 먼저 읽을 것', ''),
|
||||||
|
('', '이 키워드는 검색 패턴으로 생성한 것이지 실제 검색 데이터가 아니다.'),
|
||||||
|
('', '네이버 검색광고 > 도구 > 키워드도구 에서 월간검색수를 받아 [월간검색수] 열을 채운 뒤'),
|
||||||
|
('', '월 10 미만은 걷어내야 한다. 앞선 단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다.'),
|
||||||
|
('', ''),
|
||||||
|
('티어의 뜻', ''),
|
||||||
|
('주력', '페이지의 대표 키워드 후보. 한 페이지에 1개만 쓴다.'),
|
||||||
|
('보조', 'h2/h3 와 본문에 배치. 페이지당 3~5개.'),
|
||||||
|
('롱테일', '블로그·상세 페이지용. 검색량 확인 후 취사선택.'),
|
||||||
|
('태그', '사이트 필터 UI 용. SEO 키워드가 아니다.'),
|
||||||
|
('', ''),
|
||||||
|
('한 페이지에 몇 개를 넣나', ''),
|
||||||
|
('', 'title 1개 · h1 1개 · meta description 2~3개 · h2/h3 3~5개 · 본문 5~10개'),
|
||||||
|
('', 'meta keywords 태그는 쓰지 않는다 (구글은 2009년부터 랭킹에 반영하지 않는다).'),
|
||||||
|
('', '한 페이지에 주력을 여러 개 넣으면 주제가 희석돼 어느 것으로도 안 잡힌다.'),
|
||||||
|
('', ''),
|
||||||
|
('AEO (답변엔진)', ''),
|
||||||
|
('', '[카테고리=질문형] 행이 AEO 용이다. FAQPage 구조화 데이터로 8~15쌍 넣는다.'),
|
||||||
|
('', '답변은 2~3문장, 업체 정보에 근거한 사실만 쓴다.'),
|
||||||
|
('', ''),
|
||||||
|
('다음 단계', ''),
|
||||||
|
('', '1. 키워드도구로 [월간검색수]·[경쟁도] 채우기'),
|
||||||
|
('', '2. 월 10 미만 행 제거'),
|
||||||
|
('', '3. 관련도 높음 + 볼륨 중간 + 경쟁 낮음 조합을 우선 채택'),
|
||||||
|
('', '4. [추천 배치] 열대로 페이지에 배분'),
|
||||||
|
]
|
||||||
|
ws.append(['항목', '내용'])
|
||||||
|
for a, b in guide:
|
||||||
|
ws.append([a, b])
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2):
|
||||||
|
bold = bool(row[0].value) and not row[1].value
|
||||||
|
row[0].font = Font(size=10, bold=True, color=ACC if bold else INK)
|
||||||
|
row[1].font = Font(size=10, color=INK)
|
||||||
|
row[1].alignment = Alignment(wrap_text=True, vertical='center')
|
||||||
|
style_header(ws, 2)
|
||||||
|
widths(ws, [22, 100])
|
||||||
|
|
||||||
|
wb.save(OUT)
|
||||||
|
print(f'✅ {OUT}')
|
||||||
|
print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets))
|
||||||
|
print(f' 키워드 {len(items):,}행 / 지역 {len(regions)}개')
|
||||||
90
ontology/scripts/import-related.ts
Normal file
90
ontology/scripts/import-related.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 외부 연관키워드·검색량을 데이터셋에 병합한다.
|
||||||
|
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
|
||||||
|
*
|
||||||
|
* 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON.
|
||||||
|
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
|
||||||
|
*
|
||||||
|
* API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이
|
||||||
|
* 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고,
|
||||||
|
* 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||||
|
|
||||||
|
const DATASET = 'data/gunsan-pension-keywords.json';
|
||||||
|
|
||||||
|
interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null }
|
||||||
|
|
||||||
|
function parse(path: string): Related[] {
|
||||||
|
const raw = readFileSync(path, 'utf8');
|
||||||
|
if (path.endsWith('.json')) {
|
||||||
|
return (JSON.parse(raw) as any[]).map(toRelated);
|
||||||
|
}
|
||||||
|
const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#'));
|
||||||
|
const head = lines.shift()!.split(',').map((h) => h.trim());
|
||||||
|
return lines.map((line) => {
|
||||||
|
const cells = line.split(',').map((c) => c.trim());
|
||||||
|
const o: Record<string, string> = {};
|
||||||
|
head.forEach((h, i) => (o[h] = cells[i] ?? ''));
|
||||||
|
return toRelated(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRelated(o: any): Related {
|
||||||
|
const num = (v: unknown) => {
|
||||||
|
const n = Number(String(v ?? '').replace(/[^0-9]/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
keyword: String(o.relKeyword ?? o.keyword ?? '').trim(),
|
||||||
|
volumePc: num(o.monthlyPcQcCnt),
|
||||||
|
volumeMobile: num(o.monthlyMobileQcCnt),
|
||||||
|
competition: o.compIdx ? String(o.compIdx).trim() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const file = process.argv[2];
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
if (!file) { console.error('사용법: tsx scripts/import-related.ts <csv|json> [--apply]'); process.exit(1); }
|
||||||
|
|
||||||
|
const ds = JSON.parse(readFileSync(DATASET, 'utf8'));
|
||||||
|
const existing = new Map<string, any>(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i]));
|
||||||
|
|
||||||
|
const rows = parse(file).filter((r) => r.keyword);
|
||||||
|
let added = 0, enriched = 0, skipped = 0;
|
||||||
|
const newItems: any[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const canonical = canonicalizeKeyword(r.keyword);
|
||||||
|
const norm = normalizeKeyword(canonical);
|
||||||
|
if (!norm || isBanned(canonical)) { skipped++; continue; }
|
||||||
|
const volume = r.volumePc + r.volumeMobile;
|
||||||
|
const hit = existing.get(norm);
|
||||||
|
if (hit) {
|
||||||
|
hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad';
|
||||||
|
enriched++;
|
||||||
|
} else {
|
||||||
|
const item = {
|
||||||
|
keyword: canonical, intent: 'local', kind: 'keyword', category: '연관',
|
||||||
|
relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad',
|
||||||
|
};
|
||||||
|
newItems.push(item); existing.set(norm, item); added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`);
|
||||||
|
if (newItems.length) {
|
||||||
|
console.log('\n신규 예시');
|
||||||
|
for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`);
|
||||||
|
}
|
||||||
|
if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; }
|
||||||
|
|
||||||
|
ds.items = [...ds.items, ...newItems];
|
||||||
|
ds.count = ds.items.length;
|
||||||
|
writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n');
|
||||||
|
console.log(`\n✅ ${DATASET} → ${ds.count}건`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
103
ontology/scripts/ingest-dataset.ts
Normal file
103
ontology/scripts/ingest-dataset.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* data/gunsan-pension-keywords.json 을 pgvector 에 적재한다.
|
||||||
|
* npx tsx scripts/ingest-dataset.ts
|
||||||
|
*
|
||||||
|
* 정책: 주기 수집 없음. 고정 데이터셋 1회 적재.
|
||||||
|
* 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고,
|
||||||
|
* 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조)
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createSql, toVector } from '../src/db/db';
|
||||||
|
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
|
||||||
|
import { LocalEmbeddingProvider } from '../src/embedding/local.provider';
|
||||||
|
import { MockEmbeddingProvider } from '../src/embedding/mock.provider';
|
||||||
|
import { env } from '../src/config/env';
|
||||||
|
|
||||||
|
type Item = { keyword: string; intent: string; kind: string; category: string; relevance: number };
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sql = createSql();
|
||||||
|
const embedder =
|
||||||
|
env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider();
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync('data/gunsan-pension-keywords.json', 'utf8'));
|
||||||
|
const items: Item[] = raw.items;
|
||||||
|
console.log(`📦 데이터셋 ${items.length}건 · 임베딩 ${embedder.name}`);
|
||||||
|
|
||||||
|
// 1) 파일 내 어휘 중복 정리
|
||||||
|
const byNorm = new Map<string, { item: Item; aliases: string[] }>();
|
||||||
|
let banned = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
const canonical = canonicalizeKeyword(it.keyword);
|
||||||
|
const norm = normalizeKeyword(it.keyword);
|
||||||
|
if (!norm || isBanned(canonical)) { banned++; continue; }
|
||||||
|
const hit = byNorm.get(norm);
|
||||||
|
if (hit) {
|
||||||
|
if (!hit.aliases.includes(canonical) && hit.item.keyword !== canonical) hit.aliases.push(canonical);
|
||||||
|
if (it.relevance > hit.item.relevance) hit.item = it;
|
||||||
|
} else {
|
||||||
|
byNorm.set(norm, { item: { ...it, keyword: canonical }, aliases: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uniq = [...byNorm.entries()];
|
||||||
|
console.log(` 어휘 중복제거 → ${uniq.length}건 (병합 ${items.length - uniq.length - banned}, 금칙어 ${banned})`);
|
||||||
|
|
||||||
|
// 2) 임베딩 (배치)
|
||||||
|
const t0 = Date.now();
|
||||||
|
const vecs = await embedder.embed(uniq.map(([, v]) => v.item.keyword), 'passage');
|
||||||
|
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
|
||||||
|
|
||||||
|
// 3) 적재
|
||||||
|
const region = 'kr.jeonbuk.gunsan';
|
||||||
|
const industry = 'stay.pension';
|
||||||
|
let inserted = 0, updated = 0;
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
for (let i = 0; i < uniq.length; i++) {
|
||||||
|
const [norm, v] = uniq[i];
|
||||||
|
const res = await tx<Array<{ inserted: boolean }>>`
|
||||||
|
INSERT INTO keyword
|
||||||
|
(canonical, normalized, locale, aliases, intent, kind, category, source,
|
||||||
|
industry_id, region_id, embedding)
|
||||||
|
VALUES (${v.item.keyword}, ${norm}, 'ko-KR', ${v.aliases},
|
||||||
|
${v.item.intent}::keyword_intent, ${v.item.kind}, ${v.item.category}, 'dataset',
|
||||||
|
${industry}, ${region}, ${toVector(vecs[i])}::vector)
|
||||||
|
ON CONFLICT (normalized, locale) DO UPDATE SET
|
||||||
|
canonical = EXCLUDED.canonical, aliases = EXCLUDED.aliases, intent = EXCLUDED.intent,
|
||||||
|
kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source,
|
||||||
|
embedding = EXCLUDED.embedding, updated_at = now()
|
||||||
|
RETURNING (xmax = 0) AS inserted`;
|
||||||
|
res[0]?.inserted ? inserted++ : updated++;
|
||||||
|
if (i % 100 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
||||||
|
|
||||||
|
// 4) 데이터셋에서 빠진 행 정리.
|
||||||
|
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
|
||||||
|
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
|
||||||
|
const wanted = uniq.map(([norm]) => norm);
|
||||||
|
const stale = await sql<Array<{ canonical: string }>>`
|
||||||
|
DELETE FROM keyword
|
||||||
|
WHERE source = 'dataset' AND locale = 'ko-KR' AND NOT (normalized = ANY(${wanted}))
|
||||||
|
RETURNING canonical`;
|
||||||
|
console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제` +
|
||||||
|
(stale.length ? ` (예: ${stale.slice(0, 4).map((r) => r.canonical).join(', ')})` : ''));
|
||||||
|
|
||||||
|
const [{ n: total }] = await sql<Array<{ n: number }>>`
|
||||||
|
SELECT count(*)::int AS n FROM keyword WHERE source = 'dataset'`;
|
||||||
|
console.log(` 📚 사전 현재 ${total}건`);
|
||||||
|
|
||||||
|
// 5) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
|
||||||
|
const near = await sql<Array<{ a: string; b: string; sim: number }>>`
|
||||||
|
SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim
|
||||||
|
FROM keyword k1 JOIN keyword k2
|
||||||
|
ON k1.id < k2.id AND k1.embedding <=> k2.embedding < 0.02
|
||||||
|
WHERE k1.source = 'dataset' AND k2.source = 'dataset'
|
||||||
|
ORDER BY sim DESC LIMIT 15`;
|
||||||
|
console.log(`\n🔍 벡터 근접쌍 검토 목록 (cos ≥ 0.98, 자동 병합 안 함) — 상위 ${near.length}`);
|
||||||
|
for (const n of near) console.log(` ${Number(n.sim).toFixed(4)} ${n.a} ↔ ${n.b}`);
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('❌', e); process.exit(1); });
|
||||||
105
ontology/scripts/ingest-nationwide.ts
Normal file
105
ontology/scripts/ingest-nationwide.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 전국 지역별 펜션 키워드를 pgvector 에 적재한다.
|
||||||
|
* npx tsx scripts/ingest-nationwide.ts
|
||||||
|
*
|
||||||
|
* 군산 상세 데이터셋(source='dataset')과 공존시킨다.
|
||||||
|
* 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createSql, toVector } from '../src/db/db';
|
||||||
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||||
|
import { LocalEmbeddingProvider } from '../src/embedding/local.provider';
|
||||||
|
import { MockEmbeddingProvider } from '../src/embedding/mock.provider';
|
||||||
|
import { env } from '../src/config/env';
|
||||||
|
|
||||||
|
const SOURCE = 'nationwide';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
sido: string; region: string; regionKey: string; regionType: string;
|
||||||
|
keyword: string; kind: string; intent: string; category: string; tier: string; relevance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sql = createSql();
|
||||||
|
const embedder =
|
||||||
|
env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider();
|
||||||
|
|
||||||
|
const ds = JSON.parse(readFileSync('data/nationwide-pension-keywords.json', 'utf8'));
|
||||||
|
const items: Item[] = ds.items;
|
||||||
|
const regions = JSON.parse(readFileSync('data/regions.json', 'utf8')).regions as
|
||||||
|
Array<{ sido: string; name: string; key: string }>;
|
||||||
|
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
|
||||||
|
|
||||||
|
// 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다.
|
||||||
|
const nodes = new Map<string, string>();
|
||||||
|
for (const r of regions) {
|
||||||
|
const sidoKey = r.key.split('.').slice(0, -1).join('.');
|
||||||
|
nodes.set(sidoKey, r.sido);
|
||||||
|
nodes.set(r.key, r.name);
|
||||||
|
}
|
||||||
|
nodes.set('kr', '대한민국');
|
||||||
|
for (const [key, name] of [...nodes].sort((a, b) => a[0].length - b[0].length)) {
|
||||||
|
await sql`INSERT INTO region (id, path, name) VALUES (${key}, ${key}::ltree, ${name})
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
|
||||||
|
}
|
||||||
|
console.log(` 🗺 지역 노드 ${nodes.size}개 등록`);
|
||||||
|
|
||||||
|
// 2) 어휘 중복 정리 — 키는 (지역, 정규화 키워드)
|
||||||
|
const byKey = new Map<string, { item: Item; norm: string }>();
|
||||||
|
let banned = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
const canonical = canonicalizeKeyword(it.keyword);
|
||||||
|
const n = normalizeKeyword(canonical);
|
||||||
|
if (!n || isBanned(canonical)) { banned++; continue; }
|
||||||
|
const k = `${it.regionKey}|${n}`;
|
||||||
|
if (!byKey.has(k)) byKey.set(k, { item: { ...it, keyword: canonical }, norm: n });
|
||||||
|
}
|
||||||
|
const uniq = [...byKey.values()];
|
||||||
|
console.log(` 어휘 중복제거 → ${uniq.length}건 (금칙어 ${banned})`);
|
||||||
|
|
||||||
|
// 3) 임베딩
|
||||||
|
const t0 = Date.now();
|
||||||
|
const vecs = await embedder.embed(uniq.map((u) => u.item.keyword), 'passage');
|
||||||
|
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
|
||||||
|
|
||||||
|
// 4) 적재.
|
||||||
|
// keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면
|
||||||
|
// 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가
|
||||||
|
// 자연히 구분되므로 문제되지 않는다.
|
||||||
|
let inserted = 0, updated = 0;
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
for (let i = 0; i < uniq.length; i++) {
|
||||||
|
const { item, norm } = uniq[i];
|
||||||
|
const res = await tx<Array<{ inserted: boolean }>>`
|
||||||
|
INSERT INTO keyword
|
||||||
|
(canonical, normalized, locale, aliases, intent, kind, category, source,
|
||||||
|
industry_id, region_id, embedding)
|
||||||
|
VALUES (${item.keyword}, ${norm}, 'ko-KR', ${[]},
|
||||||
|
${item.intent}::keyword_intent, ${item.kind}, ${item.category}, ${SOURCE},
|
||||||
|
'stay.pension', ${item.kind === 'tag' ? null : item.regionKey},
|
||||||
|
${toVector(vecs[i])}::vector)
|
||||||
|
ON CONFLICT (normalized, locale) DO UPDATE SET
|
||||||
|
canonical = EXCLUDED.canonical, intent = EXCLUDED.intent, kind = EXCLUDED.kind,
|
||||||
|
category = EXCLUDED.category, source = EXCLUDED.source,
|
||||||
|
region_id = COALESCE(keyword.region_id, EXCLUDED.region_id),
|
||||||
|
embedding = EXCLUDED.embedding, updated_at = now()
|
||||||
|
RETURNING (xmax = 0) AS inserted`;
|
||||||
|
res[0]?.inserted ? inserted++ : updated++;
|
||||||
|
if (i % 500 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
||||||
|
|
||||||
|
// 5) 이 출처 안에서만 잔여 정리
|
||||||
|
const wanted = uniq.map((u) => u.norm);
|
||||||
|
const stale = await sql`
|
||||||
|
DELETE FROM keyword WHERE source = ${SOURCE} AND NOT (normalized = ANY(${wanted})) RETURNING id`;
|
||||||
|
console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제`);
|
||||||
|
|
||||||
|
const counts = await sql<Array<{ source: string; n: number }>>`
|
||||||
|
SELECT source, count(*)::int AS n FROM keyword GROUP BY source ORDER BY n DESC`;
|
||||||
|
console.log(' 📚 사전 현황: ' + counts.map((c) => `${c.source} ${c.n}`).join(' · '));
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('❌', e); process.exit(1); });
|
||||||
35
ontology/scripts/purge-nondataset.ts
Normal file
35
ontology/scripts/purge-nondataset.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 고정 데이터셋 정책 위반분 정리.
|
||||||
|
* npx tsx scripts/purge-nondataset.ts [--apply]
|
||||||
|
*
|
||||||
|
* 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가
|
||||||
|
* 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다.
|
||||||
|
*/
|
||||||
|
import { createSql } from '../src/db/db';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const sql = createSql();
|
||||||
|
|
||||||
|
const rows = await sql<Array<{ source: string; n: number; sample: string[] }>>`
|
||||||
|
SELECT source, count(*)::int AS n, (array_agg(canonical ORDER BY canonical))[1:6] AS sample
|
||||||
|
FROM keyword GROUP BY source ORDER BY n DESC`;
|
||||||
|
console.log('출처별 현황');
|
||||||
|
for (const r of rows) console.log(` ${r.source.padEnd(10)} ${String(r.n).padStart(5)} ${r.sample.join(', ')}`);
|
||||||
|
|
||||||
|
const doomed = await sql<Array<{ n: number }>>`
|
||||||
|
SELECT count(*)::int AS n FROM keyword WHERE source NOT IN ('dataset', 'manual')`;
|
||||||
|
const n = doomed[0]?.n ?? 0;
|
||||||
|
if (n === 0) { console.log('\n정리 대상 없음'); await sql.end(); return; }
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
console.log(`\n정리 대상 ${n}건. 실제로 지우려면 --apply 를 붙일 것.`);
|
||||||
|
await sql.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const del = await sql`DELETE FROM keyword WHERE source NOT IN ('dataset', 'manual') RETURNING id`;
|
||||||
|
console.log(`\n✅ ${del.length}건 삭제 (merchant_keyword 는 CASCADE)`);
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('❌', e); process.exit(1); });
|
||||||
3
ontology/scripts/requirements.txt
Normal file
3
ontology/scripts/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# 엑셀 산출 스크립트용 (scripts/export-xlsx.py, export-db-xlsx.py)
|
||||||
|
# pip3 install -r scripts/requirements.txt
|
||||||
|
openpyxl>=3.1
|
||||||
41
ontology/scripts/setup.sh
Executable file
41
ontology/scripts/setup.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 클론 직후 로컬 세팅 한 번에.
|
||||||
|
# npm run setup
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
step() { printf '\n\033[1m▶ %s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
step "사전 점검"
|
||||||
|
command -v docker >/dev/null || { echo "❌ docker 가 필요합니다"; exit 1; }
|
||||||
|
docker info >/dev/null 2>&1 || { echo "❌ Docker Desktop 을 실행해 주세요"; exit 1; }
|
||||||
|
node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' \
|
||||||
|
|| { echo "❌ Node 20 이상이 필요합니다 (현재 $(node -v))"; exit 1; }
|
||||||
|
echo " docker ok · node $(node -v)"
|
||||||
|
|
||||||
|
step ".env 준비"
|
||||||
|
if [ -f .env ]; then echo " 이미 있음 — 건너뜀"; else cp .env.example .env; echo " .env.example → .env"; fi
|
||||||
|
|
||||||
|
step "컨테이너 기동 (postgres+pgvector, redis)"
|
||||||
|
docker compose up -d --wait
|
||||||
|
|
||||||
|
step "스키마 마이그레이션"
|
||||||
|
npm run --silent db:migrate
|
||||||
|
|
||||||
|
step "기준 데이터 시드 (업종·지역·데모 업체)"
|
||||||
|
npm run --silent db:seed
|
||||||
|
|
||||||
|
step "키워드 적재 — 군산 상세"
|
||||||
|
echo " 최초 1회 임베딩 모델을 내려받습니다 (약 120MB, 1~2분)"
|
||||||
|
npm run --silent dataset:ingest 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true
|
||||||
|
|
||||||
|
step "키워드 적재 — 전국 54개 지역"
|
||||||
|
npm run --silent dataset:ingest-nationwide 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true
|
||||||
|
|
||||||
|
step "완료"
|
||||||
|
cat <<'MSG'
|
||||||
|
npm start → http://localhost:3100
|
||||||
|
http://localhost:3100/demo 매칭 콘솔 (입력창에 "스테이 머뭄")
|
||||||
|
|
||||||
|
엑셀 산출이 필요하면: pip3 install -r scripts/requirements.txt
|
||||||
|
MSG
|
||||||
103
ontology/scripts/smoke.ts
Normal file
103
ontology/scripts/smoke.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 로컬 엔드투엔드 점검 스크립트.
|
||||||
|
* npm run db:reset && npm start (다른 터미널)
|
||||||
|
* npm run smoke
|
||||||
|
*/
|
||||||
|
const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
|
||||||
|
|
||||||
|
const j = async (method: string, path: string, body?: unknown) => {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${text}`);
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const h = (t: string) => console.log(`\n\x1b[1m${t}\x1b[0m`);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
h('0. health');
|
||||||
|
console.log(' ', await j('GET', '/health'));
|
||||||
|
|
||||||
|
h('1. site-1001 생성 (첫 업체 — 전부 신규)');
|
||||||
|
const a = await j('POST', '/v1/merchants/site-1001/generate?sync=true');
|
||||||
|
printStats(a);
|
||||||
|
|
||||||
|
h('2. site-1002 생성 (같은 강남/미용실 — 중복제거 발동)');
|
||||||
|
const b = await j('POST', '/v1/merchants/site-1002/generate?sync=true');
|
||||||
|
printStats(b);
|
||||||
|
printDetails(b);
|
||||||
|
|
||||||
|
h('3. publish 웹훅 + 표기 변형 (trigram 단계)');
|
||||||
|
const c = await j('POST', '/v1/merchants/publish', {
|
||||||
|
externalId: 'site-1003',
|
||||||
|
name: '강남 뷰티랩',
|
||||||
|
industryId: 'beauty.hair',
|
||||||
|
regionId: 'kr.seoul.gangnam',
|
||||||
|
description: '강남 미용실. 염색 전문.',
|
||||||
|
profile: { services: ['뿌리염색약', '여성펌'], features: ['주차가능'] },
|
||||||
|
sync: true,
|
||||||
|
});
|
||||||
|
printStats(c.generation);
|
||||||
|
printDetails(c.generation);
|
||||||
|
|
||||||
|
h('4. SEO payload');
|
||||||
|
const seo = await j('GET', '/v1/sites/site-1001/seo?limit=8');
|
||||||
|
console.log(' title :', seo.title);
|
||||||
|
console.log(' description:', seo.description);
|
||||||
|
console.log(' keywords :', seo.keywords.join(', '));
|
||||||
|
|
||||||
|
h('5. AEO payload');
|
||||||
|
const aeo = await j('GET', '/v1/sites/site-1001/aeo?limit=3');
|
||||||
|
for (const f of aeo.faqs) console.log(` Q. ${f.question}\n A. ${f.answer}`);
|
||||||
|
|
||||||
|
h('6. 의미 기반 키워드 검색');
|
||||||
|
const found = await j('POST', '/v1/keywords/search', { query: '강남 미용실 예약하고 싶어요', limit: 5 });
|
||||||
|
for (const r of found) console.log(` ${r.score.toFixed(3)} ${r.canonical} (${r.intent}, ${r.usage_count}개 업체)`);
|
||||||
|
|
||||||
|
h('7. 성과 피드백 → 저성과 강등');
|
||||||
|
console.log(
|
||||||
|
' ',
|
||||||
|
await j('POST', '/v1/sites/site-1001/performance', {
|
||||||
|
items: [
|
||||||
|
{ keyword: '강남 미용실 후기', impressions: 500, clicks: 0 },
|
||||||
|
{ keyword: '강남 미용실', impressions: 300, clicks: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
h('8. 비동기 큐 (BullMQ)');
|
||||||
|
console.log(' ', await j('POST', '/v1/merchants/site-2001/generate'));
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const s = await j('GET', '/v1/sites/site-2001/seo?limit=5');
|
||||||
|
if (s.keywords.length) {
|
||||||
|
console.log(' 워커 처리 완료 →', s.keywords.join(', '));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ smoke 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
function printStats(s: any) {
|
||||||
|
console.log(
|
||||||
|
` 후보 ${s.candidates} → 신규 ${s.created} / 중복(정확 ${s.matchedExact}, 표기 ${s.matchedTrigram}, 의미 ${s.matchedVector})` +
|
||||||
|
` / 차단 ${s.rejected} / 연결 ${s.linked} / QA ${s.qaCreated} (${s.durationMs}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDetails(s: any) {
|
||||||
|
for (const d of s.details ?? []) {
|
||||||
|
const sim = d.similarity != null ? ` (sim=${d.similarity.toFixed(3)} → '${d.matchedTo}')` : '';
|
||||||
|
console.log(` ${d.action.padEnd(16)} ${d.candidate}${sim}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('\n❌', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
36
ontology/src/app.module.ts
Normal file
36
ontology/src/app.module.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { Controller, Get, Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { env } from './config/env';
|
||||||
|
import { DbModule } from './db/db.module';
|
||||||
|
import { EmbeddingModule } from './embedding/embedding.module';
|
||||||
|
import { GenerationModule } from './generation/generation.module';
|
||||||
|
import { MerchantsHttpModule } from './merchants/merchants.controller.module';
|
||||||
|
import { ServingModule } from './serving/serving.module';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
class HealthController {
|
||||||
|
@Get('health')
|
||||||
|
health() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
llmProvider: env.llm.provider,
|
||||||
|
embeddingProvider: env.embedding.provider,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
DbModule,
|
||||||
|
EmbeddingModule,
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }),
|
||||||
|
GenerationModule,
|
||||||
|
MerchantsHttpModule,
|
||||||
|
ServingModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
34
ontology/src/config/env.ts
Normal file
34
ontology/src/config/env.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const num = (v: string | undefined, d: number) => (v === undefined || v === '' ? d : Number(v));
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port: num(process.env.PORT, 3100),
|
||||||
|
databaseUrl: process.env.DATABASE_URL ?? 'postgres://ontology:ontology@localhost:55432/ontology',
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST ?? 'localhost',
|
||||||
|
port: num(process.env.REDIS_PORT, 56379),
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
provider: (process.env.LLM_PROVIDER ?? 'mock') as 'mock' | 'openai',
|
||||||
|
apiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
|
model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
|
||||||
|
embeddingModel: process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small',
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
provider: (process.env.EMBEDDING_PROVIDER ?? 'local') as 'mock' | 'local' | 'openai',
|
||||||
|
localModel: process.env.EMBEDDING_LOCAL_MODEL ?? 'Xenova/multilingual-e5-small',
|
||||||
|
},
|
||||||
|
dedup: {
|
||||||
|
cosineThreshold: num(process.env.DEDUP_COSINE_THRESHOLD, 0.99),
|
||||||
|
trigramThreshold: num(process.env.DEDUP_TRIGRAM_THRESHOLD, 0.6),
|
||||||
|
candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20),
|
||||||
|
},
|
||||||
|
generation: {
|
||||||
|
targetKeywords: num(process.env.GENERATION_TARGET_KEYWORDS, 15),
|
||||||
|
refreshIntervalDays: num(process.env.REFRESH_INTERVAL_DAYS, 30),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMBEDDING_DIM = 384;
|
||||||
|
export const PROMPT_VERSION = 'kw-v1';
|
||||||
16
ontology/src/db/db.module.ts
Normal file
16
ontology/src/db/db.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Global, Module, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { createSql, Sql } from './db';
|
||||||
|
|
||||||
|
export const PG = Symbol('PG');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: PG, useFactory: () => createSql() }],
|
||||||
|
exports: [PG],
|
||||||
|
})
|
||||||
|
export class DbModule implements OnModuleDestroy {
|
||||||
|
constructor() {}
|
||||||
|
async onModuleDestroy() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Sql };
|
||||||
17
ontology/src/db/db.ts
Normal file
17
ontology/src/db/db.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
|
||||||
|
export type Sql = postgres.Sql<{}>;
|
||||||
|
|
||||||
|
export const createSql = (): Sql =>
|
||||||
|
postgres(env.databaseUrl, {
|
||||||
|
max: 10,
|
||||||
|
// pgvector 컬럼은 텍스트로 주고받는다 ('[0.1,0.2,...]')
|
||||||
|
transform: { undefined: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** number[] -> pgvector 리터럴 */
|
||||||
|
export const toVector = (v: number[]): string => `[${v.join(',')}]`;
|
||||||
|
|
||||||
|
/** postgres.js 의 JSONValue 타입 제약 우회용 캐스트 */
|
||||||
|
export const asJson = (v: unknown) => v as Parameters<Sql['json']>[0];
|
||||||
24
ontology/src/db/migrate.ts
Normal file
24
ontology/src/db/migrate.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createSql } from './db';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sql = createSql();
|
||||||
|
const dir = join(process.cwd(), 'drizzle');
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const ddl = readFileSync(join(dir, file), 'utf8');
|
||||||
|
process.stdout.write(`▶ applying ${file} ... `);
|
||||||
|
await sql.unsafe(ddl);
|
||||||
|
process.stdout.write('done\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
|
console.log('✅ migration complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('❌ migration failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
119
ontology/src/db/seed.ts
Normal file
119
ontology/src/db/seed.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { asJson, createSql } from './db';
|
||||||
|
|
||||||
|
const industries = [
|
||||||
|
['beauty', 'beauty', '뷰티'],
|
||||||
|
['beauty.hair', 'beauty.hair', '미용실'],
|
||||||
|
['beauty.nail', 'beauty.nail', '네일샵'],
|
||||||
|
['food', 'food', '음식점'],
|
||||||
|
['food.korean', 'food.korean', '한식당'],
|
||||||
|
['health', 'health', '의료'],
|
||||||
|
['health.dental', 'health.dental', '치과'],
|
||||||
|
['stay', 'stay', '숙박'],
|
||||||
|
['stay.pension', 'stay.pension', '펜션'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const regions = [
|
||||||
|
['kr', 'kr', '대한민국'],
|
||||||
|
['kr.seoul', 'kr.seoul', '서울'],
|
||||||
|
['kr.seoul.gangnam', 'kr.seoul.gangnam', '강남'],
|
||||||
|
['kr.seoul.mapo', 'kr.seoul.mapo', '마포'],
|
||||||
|
['kr.busan', 'kr.busan', '부산'],
|
||||||
|
['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'],
|
||||||
|
['kr.jeonbuk', 'kr.jeonbuk', '전북'],
|
||||||
|
['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const merchants = [
|
||||||
|
{
|
||||||
|
externalId: 'site-1001',
|
||||||
|
name: '레브살롱',
|
||||||
|
industryId: 'beauty.hair',
|
||||||
|
regionId: 'kr.seoul.gangnam',
|
||||||
|
description: '강남역 3번 출구 앞 프라이빗 헤어살롱. 1:1 디자이너 전담 시스템.',
|
||||||
|
siteUrl: 'https://rev-salon.example.com',
|
||||||
|
profile: {
|
||||||
|
services: ['남자 커트', '여성 펌', '뿌리염색', '두피 클리닉'],
|
||||||
|
features: ['주차 가능', '심야 영업', '예약제'],
|
||||||
|
priceRange: '30,000~120,000원',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: 'site-1002',
|
||||||
|
name: '헤어랩 강남점',
|
||||||
|
industryId: 'beauty.hair',
|
||||||
|
regionId: 'kr.seoul.gangnam',
|
||||||
|
description: '강남 대형 헤어샵. 염색과 클리닉 전문.',
|
||||||
|
siteUrl: 'https://hairlab.example.com',
|
||||||
|
profile: {
|
||||||
|
services: ['뿌리 염색', '여성 펌', '두피클리닉'],
|
||||||
|
features: ['주차가능', '단체 예약'],
|
||||||
|
priceRange: '25,000~150,000원',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: 'site-2001',
|
||||||
|
name: '해운대 소담한상',
|
||||||
|
industryId: 'food.korean',
|
||||||
|
regionId: 'kr.busan.haeundae',
|
||||||
|
description: '해운대 해변 인근 한정식집. 제철 해산물 코스 제공.',
|
||||||
|
siteUrl: 'https://sodam.example.com',
|
||||||
|
profile: {
|
||||||
|
services: ['한정식 코스', '점심 특선', '단체 예약'],
|
||||||
|
features: ['오션뷰', '룸 완비', '발렛파킹'],
|
||||||
|
priceRange: '25,000~80,000원',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 실제 업체. 공개 정보로 확인된 항목만 넣는다.
|
||||||
|
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
|
||||||
|
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
|
||||||
|
externalId: 'site-3001',
|
||||||
|
name: '스테이머뭄',
|
||||||
|
industryId: 'stay.pension',
|
||||||
|
regionId: 'kr.jeonbuk.gunsan',
|
||||||
|
description:
|
||||||
|
'군산 원도심 말랭이마을 옆에 자리한 독채 스테이. A동·B동 두 채를 통째로 쓰며 기준 2인, 최대 4인.',
|
||||||
|
siteUrl: 'https://www.instagram.com/staymeomoom/',
|
||||||
|
profile: {
|
||||||
|
services: ['독채 대여', 'A동', 'B동'],
|
||||||
|
features: ['독채', '프라이빗', '2인 기준', '최대 4인', '원도심', '감성숙소'],
|
||||||
|
audiences: ['커플', '친구', '가족', '혼자'],
|
||||||
|
nearby: ['말랭이마을', '신흥동 일본식가옥', '동국사', '초원사진관', '이성당',
|
||||||
|
'경암동 철길마을', '근대역사박물관', '월명공원', '시간여행마을'],
|
||||||
|
address: '전북특별자치도 군산시 절골길 18 (신흥동)',
|
||||||
|
capacity: { standard: 2, max: 4 },
|
||||||
|
buildings: 2,
|
||||||
|
unverified: ['가격', '바베큐', '스파', '주차', '애견동반'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sql = createSql();
|
||||||
|
|
||||||
|
for (const [id, path, name] of industries) {
|
||||||
|
await sql`INSERT INTO industry (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
|
||||||
|
}
|
||||||
|
for (const [id, path, name] of regions) {
|
||||||
|
await sql`INSERT INTO region (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
|
||||||
|
}
|
||||||
|
for (const m of merchants) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url)
|
||||||
|
VALUES (${m.externalId}, ${m.name}, ${m.industryId}, ${m.regionId},
|
||||||
|
${m.description}, ${sql.json(asJson(m.profile))}, ${m.siteUrl})
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name, description = EXCLUDED.description,
|
||||||
|
profile = EXCLUDED.profile, updated_at = now()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
|
console.log(`✅ seed: industry=${industries.length} region=${regions.length} merchant=${merchants.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('❌ seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
19
ontology/src/embedding/embedding.module.ts
Normal file
19
ontology/src/embedding/embedding.module.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { LocalEmbeddingProvider } from './local.provider';
|
||||||
|
import { MockEmbeddingProvider } from './mock.provider';
|
||||||
|
import { OpenAiEmbeddingProvider } from './openai.provider';
|
||||||
|
import { EmbeddingProvider } from './types';
|
||||||
|
|
||||||
|
const IMPL = {
|
||||||
|
local: LocalEmbeddingProvider,
|
||||||
|
openai: OpenAiEmbeddingProvider,
|
||||||
|
mock: MockEmbeddingProvider,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: EmbeddingProvider, useClass: IMPL[env.embedding.provider] }],
|
||||||
|
exports: [EmbeddingProvider],
|
||||||
|
})
|
||||||
|
export class EmbeddingModule {}
|
||||||
48
ontology/src/embedding/local.provider.ts
Normal file
48
ontology/src/embedding/local.provider.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { EMBEDDING_DIM, env } from '../config/env';
|
||||||
|
import { EmbedKind, EmbeddingProvider } from './types';
|
||||||
|
|
||||||
|
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
|
||||||
|
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 multilingual-e5-small (384차원, onnxruntime CPU).
|
||||||
|
* 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalEmbeddingProvider extends EmbeddingProvider {
|
||||||
|
readonly name = 'local:multilingual-e5-small';
|
||||||
|
readonly dimensions = EMBEDDING_DIM;
|
||||||
|
private readonly logger = new Logger(LocalEmbeddingProvider.name);
|
||||||
|
private extractor: any | null = null;
|
||||||
|
private loading: Promise<any> | null = null;
|
||||||
|
|
||||||
|
private async pipe() {
|
||||||
|
if (this.extractor) return this.extractor;
|
||||||
|
if (!this.loading) {
|
||||||
|
this.loading = (async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const { pipeline } = await esmImport('@huggingface/transformers');
|
||||||
|
const fe = await pipeline('feature-extraction', env.embedding.localModel);
|
||||||
|
this.logger.log(`model ready: ${env.embedding.localModel} (${Date.now() - t0}ms)`);
|
||||||
|
this.extractor = fe;
|
||||||
|
return fe;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async embed(texts: string[], kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||||
|
if (texts.length === 0) return [];
|
||||||
|
const fe = await this.pipe();
|
||||||
|
const prefixed = texts.map((t) => `${kind}: ${t}`);
|
||||||
|
const out: number[][] = [];
|
||||||
|
const BATCH = 64;
|
||||||
|
for (let i = 0; i < prefixed.length; i += BATCH) {
|
||||||
|
const slice = prefixed.slice(i, i + BATCH);
|
||||||
|
const res = await fe(slice, { pooling: 'mean', normalize: true });
|
||||||
|
out.push(...(res.tolist() as number[][]));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
ontology/src/embedding/mock.provider.ts
Normal file
14
ontology/src/embedding/mock.provider.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EMBEDDING_DIM } from '../config/env';
|
||||||
|
import { hashEmbedding } from '../llm/mock.provider';
|
||||||
|
import { EmbedKind, EmbeddingProvider } from './types';
|
||||||
|
|
||||||
|
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */
|
||||||
|
@Injectable()
|
||||||
|
export class MockEmbeddingProvider extends EmbeddingProvider {
|
||||||
|
readonly name = 'mock:bigram-hash';
|
||||||
|
readonly dimensions = EMBEDDING_DIM;
|
||||||
|
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||||
|
return texts.map((t) => hashEmbedding(t, EMBEDDING_DIM));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ontology/src/embedding/openai.provider.ts
Normal file
21
ontology/src/embedding/openai.provider.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { EMBEDDING_DIM, env } from '../config/env';
|
||||||
|
import { EmbedKind, EmbeddingProvider } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpenAiEmbeddingProvider extends EmbeddingProvider {
|
||||||
|
readonly name = `openai:${env.llm.embeddingModel}`;
|
||||||
|
readonly dimensions = EMBEDDING_DIM;
|
||||||
|
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
|
||||||
|
|
||||||
|
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||||
|
if (texts.length === 0) return [];
|
||||||
|
const res = await this.client.embeddings.create({
|
||||||
|
model: env.llm.embeddingModel,
|
||||||
|
input: texts,
|
||||||
|
dimensions: EMBEDDING_DIM, // 스키마와 차원을 맞춘다
|
||||||
|
});
|
||||||
|
return res.data.map((d) => d.embedding as number[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
ontology/src/embedding/types.ts
Normal file
8
ontology/src/embedding/types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** e5 계열은 query 와 passage 를 비대칭으로 인코딩한다 — 검색 품질에 직접 영향. */
|
||||||
|
export type EmbedKind = 'query' | 'passage';
|
||||||
|
|
||||||
|
export abstract class EmbeddingProvider {
|
||||||
|
abstract readonly name: string;
|
||||||
|
abstract readonly dimensions: number;
|
||||||
|
abstract embed(texts: string[], kind?: EmbedKind): Promise<number[][]>;
|
||||||
|
}
|
||||||
20
ontology/src/generation/generation.module.ts
Normal file
20
ontology/src/generation/generation.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { KeywordsModule } from '../keywords/keywords.module';
|
||||||
|
import { LlmModule } from '../llm/llm.module';
|
||||||
|
import { MerchantsModule } from '../merchants/merchants.module';
|
||||||
|
import { GenerationProcessor } from './generation.processor';
|
||||||
|
import { GENERATION_QUEUE, GenerationQueue } from './generation.queue';
|
||||||
|
import { GenerationService } from './generation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
BullModule.registerQueue({ name: GENERATION_QUEUE }),
|
||||||
|
LlmModule,
|
||||||
|
KeywordsModule,
|
||||||
|
MerchantsModule,
|
||||||
|
],
|
||||||
|
providers: [GenerationService, GenerationQueue, GenerationProcessor],
|
||||||
|
exports: [GenerationService, GenerationQueue],
|
||||||
|
})
|
||||||
|
export class GenerationModule {}
|
||||||
21
ontology/src/generation/generation.processor.ts
Normal file
21
ontology/src/generation/generation.processor.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { GenerationService } from './generation.service';
|
||||||
|
import { GENERATION_QUEUE, GenerationJob } from './generation.queue';
|
||||||
|
|
||||||
|
@Processor(GENERATION_QUEUE, { concurrency: 2 })
|
||||||
|
export class GenerationProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(GenerationProcessor.name);
|
||||||
|
|
||||||
|
constructor(private readonly generation: GenerationService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job<GenerationJob>) {
|
||||||
|
const { merchantId, trigger } = job.data;
|
||||||
|
this.logger.log(`processing ${job.id} (${trigger})`);
|
||||||
|
const stats = await this.generation.runForMerchant(merchantId, trigger);
|
||||||
|
return { ...stats, details: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ontology/src/generation/generation.queue.ts
Normal file
50
ontology/src/generation/generation.queue.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { MerchantsService } from '../merchants/merchants.service';
|
||||||
|
import { GenerationTrigger } from './generation.service';
|
||||||
|
|
||||||
|
export const GENERATION_QUEUE = 'keyword-generation';
|
||||||
|
|
||||||
|
export interface GenerationJob {
|
||||||
|
merchantId: string;
|
||||||
|
trigger: GenerationTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GenerationQueue {
|
||||||
|
private readonly logger = new Logger(GenerationQueue.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectQueue(GENERATION_QUEUE) private readonly queue: Queue<GenerationJob>,
|
||||||
|
private readonly merchants: MerchantsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async enqueue(merchantId: string, trigger: GenerationTrigger): Promise<string> {
|
||||||
|
// 짧은 시간 내 같은 업체가 여러 번 발행돼도 한 번만 처리 (60초 dedupe 창)
|
||||||
|
const job = await this.queue.add(
|
||||||
|
'generate',
|
||||||
|
{ merchantId, trigger },
|
||||||
|
{
|
||||||
|
deduplication: { id: `${merchantId}-${trigger}`, ttl: 60_000 },
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 500,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return String(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주기 리프레시: 매일 03:00, N일 지난 업체를 큐에 적재 */
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||||
|
async scheduleRefresh() {
|
||||||
|
const stale = await this.merchants.findStale(env.generation.refreshIntervalDays, 200);
|
||||||
|
for (const m of stale) {
|
||||||
|
await this.enqueue(m.id, 'scheduled');
|
||||||
|
}
|
||||||
|
if (stale.length) this.logger.log(`scheduled refresh queued: ${stale.length} merchants`);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ontology/src/generation/generation.service.ts
Normal file
216
ontology/src/generation/generation.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PG } from '../db/db.module';
|
||||||
|
import { asJson, Sql, toVector } from '../db/db';
|
||||||
|
import { env, PROMPT_VERSION } from '../config/env';
|
||||||
|
import { DedupAction, DedupService } from '../keywords/dedup.service';
|
||||||
|
import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize';
|
||||||
|
import { EmbeddingProvider } from '../embedding/types';
|
||||||
|
import { LlmProvider, MerchantContext } from '../llm/types';
|
||||||
|
import { MerchantsService } from '../merchants/merchants.service';
|
||||||
|
|
||||||
|
export type GenerationTrigger = 'published' | 'scheduled' | 'manual';
|
||||||
|
|
||||||
|
export interface GenerationStats {
|
||||||
|
runId: string;
|
||||||
|
merchantId: string;
|
||||||
|
merchantName: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
candidates: number;
|
||||||
|
created: number;
|
||||||
|
matchedExact: number;
|
||||||
|
matchedTrigram: number;
|
||||||
|
matchedVector: number;
|
||||||
|
rejected: number;
|
||||||
|
linked: number;
|
||||||
|
qaCreated: number;
|
||||||
|
durationMs: number;
|
||||||
|
details: Array<{ candidate: string; action: DedupAction; matchedTo?: string; similarity?: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GenerationService {
|
||||||
|
private readonly logger = new Logger(GenerationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PG) private readonly sql: Sql,
|
||||||
|
private readonly merchants: MerchantsService,
|
||||||
|
private readonly llm: LlmProvider,
|
||||||
|
private readonly embedder: EmbeddingProvider,
|
||||||
|
private readonly dedup: DedupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async runForMerchant(
|
||||||
|
idOrExternalId: string,
|
||||||
|
trigger: GenerationTrigger = 'manual',
|
||||||
|
targetCount = env.generation.targetKeywords,
|
||||||
|
): Promise<GenerationStats> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const merchant = await this.merchants.findWithTaxonomy(idOrExternalId);
|
||||||
|
|
||||||
|
const runRows = await this.sql<Array<{ id: string }>>`
|
||||||
|
INSERT INTO generation_run (merchant_id, provider, model, prompt_version, trigger, status, input)
|
||||||
|
VALUES (${merchant.id}, ${this.llm.name}, ${this.llm.model}, ${PROMPT_VERSION},
|
||||||
|
${trigger}, 'running', ${this.sql.json(asJson({ externalId: merchant.external_id }))})
|
||||||
|
RETURNING id`;
|
||||||
|
const runId = runRows[0].id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.existingKeywordsFor(merchant.id, merchant.industry_id);
|
||||||
|
const ctx: MerchantContext = {
|
||||||
|
externalId: merchant.external_id,
|
||||||
|
name: merchant.name,
|
||||||
|
description: merchant.description,
|
||||||
|
industryName: merchant.industry_name,
|
||||||
|
industryPath: merchant.industry_path,
|
||||||
|
regionName: merchant.region_name,
|
||||||
|
regionPath: merchant.region_path,
|
||||||
|
profile: merchant.profile ?? {},
|
||||||
|
existingKeywords: existing,
|
||||||
|
targetCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await this.llm.generate(ctx);
|
||||||
|
|
||||||
|
// 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다)
|
||||||
|
const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword));
|
||||||
|
const embeddings = texts.length ? await this.embedder.embed(texts, 'passage') : [];
|
||||||
|
|
||||||
|
const stats: GenerationStats = {
|
||||||
|
runId,
|
||||||
|
merchantId: merchant.id,
|
||||||
|
merchantName: merchant.name,
|
||||||
|
provider: this.llm.name,
|
||||||
|
model: output.model,
|
||||||
|
candidates: output.keywords.length,
|
||||||
|
created: 0,
|
||||||
|
matchedExact: 0,
|
||||||
|
matchedTrigram: 0,
|
||||||
|
matchedVector: 0,
|
||||||
|
rejected: 0,
|
||||||
|
linked: 0,
|
||||||
|
qaCreated: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < output.keywords.length; i++) {
|
||||||
|
const cand = output.keywords[i];
|
||||||
|
const result = await this.dedup.resolve({
|
||||||
|
raw: cand.keyword,
|
||||||
|
intent: cand.intent,
|
||||||
|
embedding: embeddings[i],
|
||||||
|
locale: 'ko-KR',
|
||||||
|
industryId: merchant.industry_id,
|
||||||
|
regionId: merchant.region_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
stats.details.push({
|
||||||
|
candidate: canonicalizeKeyword(cand.keyword),
|
||||||
|
action: result.action,
|
||||||
|
matchedTo: result.matchedTo,
|
||||||
|
similarity: result.similarity,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (result.action) {
|
||||||
|
case 'created': stats.created++; break;
|
||||||
|
case 'matched_exact': stats.matchedExact++; break;
|
||||||
|
case 'matched_trigram': stats.matchedTrigram++; break;
|
||||||
|
case 'matched_vector': stats.matchedVector++; break;
|
||||||
|
case 'rejected_banned': stats.rejected++; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.keywordId) {
|
||||||
|
const linked = await this.linkKeyword(merchant.id, result.keywordId, cand.relevance, cand.rationale);
|
||||||
|
if (linked) stats.linked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.qaCreated = await this.upsertQaPairs(merchant.id, output.qaPairs);
|
||||||
|
await this.merchants.markGenerated(merchant.id);
|
||||||
|
stats.durationMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
await this.sql`
|
||||||
|
UPDATE generation_run
|
||||||
|
SET status = 'succeeded',
|
||||||
|
output = ${this.sql.json(asJson(output))},
|
||||||
|
stats = ${this.sql.json(asJson({ ...stats, details: undefined }))},
|
||||||
|
finished_at = now()
|
||||||
|
WHERE id = ${runId}`;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[${merchant.name}] cand=${stats.candidates} new=${stats.created} ` +
|
||||||
|
`dup(exact/trg/vec)=${stats.matchedExact}/${stats.matchedTrigram}/${stats.matchedVector} ` +
|
||||||
|
`rejected=${stats.rejected} qa=${stats.qaCreated} ${stats.durationMs}ms`,
|
||||||
|
);
|
||||||
|
return stats;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await this.sql`
|
||||||
|
UPDATE generation_run
|
||||||
|
SET status = 'failed', error = ${message}, finished_at = now()
|
||||||
|
WHERE id = ${runId}`;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프롬프트에 넣을 "이미 보유한 키워드": 자기 것 + 같은 업종에서 많이 쓰는 것 */
|
||||||
|
private async existingKeywordsFor(merchantId: string, industryId: string | null): Promise<string[]> {
|
||||||
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
||||||
|
SELECT DISTINCT k.canonical
|
||||||
|
FROM keyword k
|
||||||
|
LEFT JOIN merchant_keyword mk ON mk.keyword_id = k.id AND mk.merchant_id = ${merchantId}
|
||||||
|
WHERE mk.merchant_id IS NOT NULL
|
||||||
|
OR (${industryId}::text IS NOT NULL AND k.industry_id = ${industryId} AND k.usage_count > 0)
|
||||||
|
ORDER BY k.canonical
|
||||||
|
LIMIT 100`;
|
||||||
|
return rows.map((r) => r.canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkKeyword(
|
||||||
|
merchantId: string,
|
||||||
|
keywordId: string,
|
||||||
|
relevance: number,
|
||||||
|
rationale: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const status = relevance >= 0.5 ? 'active' : 'candidate';
|
||||||
|
const rows = await this.sql<Array<{ inserted: boolean }>>`
|
||||||
|
INSERT INTO merchant_keyword (merchant_id, keyword_id, relevance, source, status, rationale)
|
||||||
|
VALUES (${merchantId}, ${keywordId}, ${relevance}, 'llm', ${status}, ${rationale})
|
||||||
|
ON CONFLICT (merchant_id, keyword_id) DO UPDATE SET
|
||||||
|
relevance = GREATEST(merchant_keyword.relevance, EXCLUDED.relevance),
|
||||||
|
rationale = COALESCE(EXCLUDED.rationale, merchant_keyword.rationale),
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING (xmax = 0) AS inserted`;
|
||||||
|
|
||||||
|
if (rows[0]?.inserted) {
|
||||||
|
await this.sql`UPDATE keyword SET usage_count = usage_count + 1 WHERE id = ${keywordId}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertQaPairs(
|
||||||
|
merchantId: string,
|
||||||
|
pairs: Array<{ question: string; answer: string }>,
|
||||||
|
): Promise<number> {
|
||||||
|
if (pairs.length === 0) return 0;
|
||||||
|
const embeddings = await this.embedder.embed(pairs.map((p) => p.question), 'passage');
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
|
const p = pairs[i];
|
||||||
|
const nq = normalizeKeyword(p.question);
|
||||||
|
if (!nq) continue;
|
||||||
|
const rows = await this.sql<Array<{ inserted: boolean }>>`
|
||||||
|
INSERT INTO qa_pair (merchant_id, question, answer, normalized_question, embedding)
|
||||||
|
VALUES (${merchantId}, ${canonicalizeKeyword(p.question)}, ${p.answer.trim()},
|
||||||
|
${nq}, ${toVector(embeddings[i])}::vector)
|
||||||
|
ON CONFLICT (merchant_id, normalized_question) DO UPDATE SET
|
||||||
|
answer = EXCLUDED.answer, updated_at = now()
|
||||||
|
RETURNING (xmax = 0) AS inserted`;
|
||||||
|
if (rows[0]?.inserted) created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ontology/src/keywords/dedup.service.ts
Normal file
113
ontology/src/keywords/dedup.service.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { KeywordIntent } from '../llm/types';
|
||||||
|
import { KeywordRepository } from './keyword.repository';
|
||||||
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from './normalize';
|
||||||
|
|
||||||
|
export type DedupAction =
|
||||||
|
| 'created' // 새 키워드
|
||||||
|
| 'matched_exact' // 1단계: 정규화 해시 일치
|
||||||
|
| 'matched_trigram' // 2단계: 표기 변형/오타
|
||||||
|
| 'matched_vector' // 3단계: 의미 중복 → alias 흡수
|
||||||
|
| 'rejected_banned'; // 금칙어
|
||||||
|
|
||||||
|
export interface DedupResult {
|
||||||
|
action: DedupAction;
|
||||||
|
keywordId: string | null;
|
||||||
|
canonical: string;
|
||||||
|
matchedTo?: string;
|
||||||
|
similarity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveInput {
|
||||||
|
raw: string;
|
||||||
|
intent: KeywordIntent;
|
||||||
|
embedding: number[];
|
||||||
|
locale: string;
|
||||||
|
industryId: string | null;
|
||||||
|
regionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4단계 계단식 중복제거.
|
||||||
|
* 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DedupService {
|
||||||
|
private readonly logger = new Logger(DedupService.name);
|
||||||
|
|
||||||
|
constructor(private readonly repo: KeywordRepository) {}
|
||||||
|
|
||||||
|
async resolve(input: ResolveInput): Promise<DedupResult> {
|
||||||
|
const canonical = canonicalizeKeyword(input.raw);
|
||||||
|
const normalized = normalizeKeyword(input.raw);
|
||||||
|
|
||||||
|
// 0단계 — 금칙어/과장광고 차단
|
||||||
|
if (!normalized || isBanned(canonical)) {
|
||||||
|
return { action: 'rejected_banned', keywordId: null, canonical };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1단계 — 정규화 완전 일치 (공백/구두점 차이 흡수)
|
||||||
|
const exact = await this.repo.findByNormalized(normalized, input.locale);
|
||||||
|
if (exact) {
|
||||||
|
await this.repo.absorbAlias(exact.id, canonical);
|
||||||
|
return {
|
||||||
|
action: 'matched_exact',
|
||||||
|
keywordId: exact.id,
|
||||||
|
canonical: exact.canonical,
|
||||||
|
matchedTo: exact.canonical,
|
||||||
|
similarity: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
|
||||||
|
//
|
||||||
|
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
|
||||||
|
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
|
||||||
|
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
|
||||||
|
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
|
||||||
|
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
|
||||||
|
const candidates = await this.repo.findDedupCandidates(
|
||||||
|
input.embedding,
|
||||||
|
normalized,
|
||||||
|
input.locale,
|
||||||
|
env.dedup.candidateLimit,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigramHit = candidates.find((c) => c.trg >= env.dedup.trigramThreshold);
|
||||||
|
if (trigramHit) {
|
||||||
|
await this.repo.absorbAlias(trigramHit.id, canonical);
|
||||||
|
return {
|
||||||
|
action: 'matched_trigram',
|
||||||
|
keywordId: trigramHit.id,
|
||||||
|
canonical: trigramHit.canonical,
|
||||||
|
matchedTo: trigramHit.canonical,
|
||||||
|
similarity: trigramHit.trg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = candidates[0];
|
||||||
|
if (best && best.cosine >= env.dedup.cosineThreshold) {
|
||||||
|
await this.repo.absorbAlias(best.id, canonical);
|
||||||
|
return {
|
||||||
|
action: 'matched_vector',
|
||||||
|
keywordId: best.id,
|
||||||
|
canonical: best.canonical,
|
||||||
|
matchedTo: best.canonical,
|
||||||
|
similarity: best.cosine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4단계 — 신규 등록
|
||||||
|
const created = await this.repo.insert({
|
||||||
|
canonical,
|
||||||
|
normalized,
|
||||||
|
locale: input.locale,
|
||||||
|
intent: input.intent,
|
||||||
|
embedding: input.embedding,
|
||||||
|
industryId: input.industryId,
|
||||||
|
regionId: input.regionId,
|
||||||
|
});
|
||||||
|
return { action: 'created', keywordId: created.id, canonical: created.canonical };
|
||||||
|
}
|
||||||
|
}
|
||||||
124
ontology/src/keywords/keyword.repository.ts
Normal file
124
ontology/src/keywords/keyword.repository.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PG } from '../db/db.module';
|
||||||
|
import { Sql, toVector } from '../db/db';
|
||||||
|
import { KeywordIntent } from '../llm/types';
|
||||||
|
|
||||||
|
export interface KeywordRow {
|
||||||
|
id: string;
|
||||||
|
canonical: string;
|
||||||
|
normalized: string;
|
||||||
|
aliases: string[];
|
||||||
|
intent: KeywordIntent;
|
||||||
|
usage_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateRow {
|
||||||
|
id: string;
|
||||||
|
canonical: string;
|
||||||
|
normalized: string;
|
||||||
|
cosine: number;
|
||||||
|
trg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KeywordRepository {
|
||||||
|
constructor(@Inject(PG) private readonly sql: Sql) {}
|
||||||
|
|
||||||
|
async findByNormalized(normalized: string, locale: string): Promise<KeywordRow | null> {
|
||||||
|
const rows = await this.sql<KeywordRow[]>`
|
||||||
|
SELECT id, canonical, normalized, aliases, intent, usage_count
|
||||||
|
FROM keyword
|
||||||
|
WHERE normalized = ${normalized} AND locale = ${locale}
|
||||||
|
LIMIT 1`;
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다.
|
||||||
|
* 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다.
|
||||||
|
*/
|
||||||
|
async findDedupCandidates(
|
||||||
|
embedding: number[],
|
||||||
|
normalized: string,
|
||||||
|
locale: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<CandidateRow[]> {
|
||||||
|
const vec = toVector(embedding);
|
||||||
|
const rows = await this.sql<CandidateRow[]>`
|
||||||
|
(
|
||||||
|
SELECT id, canonical, normalized,
|
||||||
|
1 - (embedding <=> ${vec}::vector) AS cosine,
|
||||||
|
similarity(normalized, ${normalized}) AS trg
|
||||||
|
FROM keyword
|
||||||
|
WHERE locale = ${locale}
|
||||||
|
AND embedding IS NOT NULL
|
||||||
|
AND normalized % ${normalized}
|
||||||
|
ORDER BY trg DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
(
|
||||||
|
SELECT id, canonical, normalized,
|
||||||
|
1 - (embedding <=> ${vec}::vector) AS cosine,
|
||||||
|
0::real AS trg
|
||||||
|
FROM keyword
|
||||||
|
WHERE locale = ${locale}
|
||||||
|
AND embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> ${vec}::vector
|
||||||
|
LIMIT ${limit}
|
||||||
|
)`;
|
||||||
|
|
||||||
|
const best = new Map<string, CandidateRow>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const prev = best.get(r.id);
|
||||||
|
if (!prev || r.trg > prev.trg) best.set(r.id, { ...r, cosine: Number(r.cosine), trg: Number(r.trg) });
|
||||||
|
}
|
||||||
|
return [...best.values()].sort((a, b) => b.cosine - a.cosine);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert(input: {
|
||||||
|
canonical: string;
|
||||||
|
normalized: string;
|
||||||
|
locale: string;
|
||||||
|
intent: KeywordIntent;
|
||||||
|
embedding: number[];
|
||||||
|
industryId: string | null;
|
||||||
|
regionId: string | null;
|
||||||
|
}): Promise<KeywordRow> {
|
||||||
|
const rows = await this.sql<KeywordRow[]>`
|
||||||
|
INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count)
|
||||||
|
VALUES (${input.canonical}, ${input.normalized}, ${input.locale}, ${input.intent},
|
||||||
|
${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0)
|
||||||
|
ON CONFLICT (normalized, locale) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id, canonical, normalized, aliases, intent, usage_count`;
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 표기 변형을 기존 키워드에 흡수 (롱테일 검색어 보존) */
|
||||||
|
async absorbAlias(keywordId: string, alias: string): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
UPDATE keyword
|
||||||
|
SET aliases = (
|
||||||
|
SELECT ARRAY(SELECT DISTINCT unnest(aliases || ARRAY[${alias}]::text[]))
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ${keywordId}
|
||||||
|
AND NOT (${alias} = ANY(aliases))
|
||||||
|
AND canonical <> ${alias}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bumpUsage(keywordId: string): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
UPDATE keyword SET usage_count = usage_count + 1, updated_at = now() WHERE id = ${keywordId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByVector(embedding: number[], locale: string, limit: number) {
|
||||||
|
const vec = toVector(embedding);
|
||||||
|
return this.sql<Array<{ id: string; canonical: string; intent: string; usage_count: number; score: number }>>`
|
||||||
|
SELECT id, canonical, intent, usage_count, 1 - (embedding <=> ${vec}::vector) AS score
|
||||||
|
FROM keyword
|
||||||
|
WHERE locale = ${locale} AND embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> ${vec}::vector
|
||||||
|
LIMIT ${limit}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ontology/src/keywords/keywords.module.ts
Normal file
9
ontology/src/keywords/keywords.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DedupService } from './dedup.service';
|
||||||
|
import { KeywordRepository } from './keyword.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [KeywordRepository, DedupService],
|
||||||
|
exports: [KeywordRepository, DedupService],
|
||||||
|
})
|
||||||
|
export class KeywordsModule {}
|
||||||
33
ontology/src/keywords/normalize.ts
Normal file
33
ontology/src/keywords/normalize.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 중복 판정용 정규화.
|
||||||
|
* NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거.
|
||||||
|
* "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다.
|
||||||
|
*/
|
||||||
|
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
|
||||||
|
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;
|
||||||
|
|
||||||
|
export function normalizeKeyword(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(ZERO_WIDTH, '')
|
||||||
|
.replace(PUNCT, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 표시용 정리: 앞뒤/중복 공백만 정리하고 원문 표기는 보존 */
|
||||||
|
export function canonicalizeKeyword(raw: string): string {
|
||||||
|
return raw.normalize('NFKC').replace(ZERO_WIDTH, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 과장광고·금칙 표현 필터 (광고심의 리스크 차단) */
|
||||||
|
const BANNED = [
|
||||||
|
'최고', '1위', '일등', '넘버원', 'no.1', '100%', '무조건', '완치', '부작용없',
|
||||||
|
'영구', '평생보장', '유일한', '최저가보장', '전국최대',
|
||||||
|
];
|
||||||
|
const BANNED_NORMALIZED = BANNED.map(normalizeKeyword);
|
||||||
|
|
||||||
|
export function isBanned(text: string): boolean {
|
||||||
|
const n = normalizeKeyword(text);
|
||||||
|
return BANNED_NORMALIZED.some((b) => b.length > 0 && n.includes(b));
|
||||||
|
}
|
||||||
16
ontology/src/llm/llm.module.ts
Normal file
16
ontology/src/llm/llm.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { MockLlmProvider } from './mock.provider';
|
||||||
|
import { OpenAiLlmProvider } from './openai.provider';
|
||||||
|
import { LlmProvider } from './types';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: LlmProvider,
|
||||||
|
useClass: env.llm.provider === 'openai' ? OpenAiLlmProvider : MockLlmProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [LlmProvider],
|
||||||
|
})
|
||||||
|
export class LlmModule {}
|
||||||
160
ontology/src/llm/mock.provider.ts
Normal file
160
ontology/src/llm/mock.provider.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EMBEDDING_DIM } from '../config/env';
|
||||||
|
import { normalizeKeyword } from '../keywords/normalize';
|
||||||
|
import {
|
||||||
|
GenerationOutput,
|
||||||
|
KeywordCandidate,
|
||||||
|
KeywordIntent,
|
||||||
|
LlmProvider,
|
||||||
|
MerchantContext,
|
||||||
|
QaCandidate,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현.
|
||||||
|
*
|
||||||
|
* embed(): 문자 bigram 해싱 + L2 정규화.
|
||||||
|
* 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로
|
||||||
|
* 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MockLlmProvider extends LlmProvider {
|
||||||
|
readonly name = 'mock';
|
||||||
|
readonly model = 'mock-keyword-v1';
|
||||||
|
|
||||||
|
|
||||||
|
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
|
||||||
|
const region = ctx.regionName ?? '';
|
||||||
|
const industry = ctx.industryName ?? '업체';
|
||||||
|
const p = ctx.profile;
|
||||||
|
const services = toStringArray(p['services']);
|
||||||
|
const features = toStringArray(p['features']);
|
||||||
|
const audiences = toStringArray(p['audiences']);
|
||||||
|
const nearby = toStringArray(p['nearby']);
|
||||||
|
const seasons = toStringArray(p['seasons']);
|
||||||
|
|
||||||
|
const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약'];
|
||||||
|
const out: Array<[string, KeywordIntent, number]> = [];
|
||||||
|
const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]);
|
||||||
|
|
||||||
|
// 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다.
|
||||||
|
push(ctx.name, 'brand', 0.99);
|
||||||
|
push(`${region} ${ctx.name}`, 'brand', 0.97);
|
||||||
|
push(`${region} ${industry}`, 'local', 0.95);
|
||||||
|
push(`${region} ${industry} 추천`, 'local', 0.93);
|
||||||
|
|
||||||
|
for (const m of MODIFIERS) {
|
||||||
|
push(`${region} ${industry} ${m}`, intentOf(m), 0.86);
|
||||||
|
}
|
||||||
|
for (const a of audiences) {
|
||||||
|
push(`${region} ${a} ${industry}`, 'local', 0.88);
|
||||||
|
for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74);
|
||||||
|
push(`${a} ${industry} 추천`, 'informational', 0.62);
|
||||||
|
}
|
||||||
|
for (const f of features) {
|
||||||
|
push(`${region} ${f} ${industry}`, 'local', 0.84);
|
||||||
|
push(`${industry} ${f}`, 'informational', 0.6);
|
||||||
|
push(`${region} ${industry} ${f}`, 'local', 0.7);
|
||||||
|
}
|
||||||
|
for (const s of services) {
|
||||||
|
push(`${region} ${s}`, 'local', 0.82);
|
||||||
|
for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66);
|
||||||
|
}
|
||||||
|
for (const n of nearby) {
|
||||||
|
push(`${n} 근처 ${industry}`, 'local', 0.8);
|
||||||
|
push(`${n} ${industry} 추천`, 'local', 0.76);
|
||||||
|
push(`${n} 숙소`, 'local', 0.68);
|
||||||
|
}
|
||||||
|
for (const s of seasons) {
|
||||||
|
push(`${s} ${region} ${industry}`, 'local', 0.72);
|
||||||
|
push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64);
|
||||||
|
}
|
||||||
|
// 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다
|
||||||
|
for (const a of audiences) {
|
||||||
|
for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42);
|
||||||
|
}
|
||||||
|
for (const a of audiences) {
|
||||||
|
for (const s of services) push(`${a} ${s}`, 'informational', 0.38);
|
||||||
|
}
|
||||||
|
// 질문형 (AEO 유입)
|
||||||
|
for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5);
|
||||||
|
for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const keywords: KeywordCandidate[] = [];
|
||||||
|
for (const [raw, intent, relevance] of out) {
|
||||||
|
const k = raw.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!k || seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` });
|
||||||
|
if (keywords.length >= ctx.targetCount) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qaPairs: QaCandidate[] = [
|
||||||
|
{
|
||||||
|
question: `${ctx.name}은(는) 어디에 있나요?`,
|
||||||
|
answer: `${ctx.name}은(는) ${region || '해당 지역'}에 위치한 ${industry}입니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `${ctx.name} 예약은 어떻게 하나요?`,
|
||||||
|
answer: `${ctx.name}은(는) 사이트 예약 페이지 또는 전화로 예약할 수 있습니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `${ctx.name}의 주요 서비스는 무엇인가요?`,
|
||||||
|
answer: services.length
|
||||||
|
? `주요 서비스는 ${services.join(', ')} 입니다.`
|
||||||
|
: `${industry} 관련 서비스를 제공합니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`,
|
||||||
|
answer: nearby.length
|
||||||
|
? `${nearby.join(', ')} 등이 가깝습니다.`
|
||||||
|
: `${region} 주요 명소가 인근에 있습니다.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `${ctx.name}에 ${audiences[0] ?? '반려동물'}도 갈 수 있나요?`,
|
||||||
|
answer: features.length
|
||||||
|
? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.`
|
||||||
|
: `예약 전 상세 조건을 확인해 주세요.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { keywords, qaPairs, model: this.model, provider: this.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function intentOf(modifier: string): KeywordIntent {
|
||||||
|
if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional';
|
||||||
|
if (modifier === '후기' || modifier === '순위') return 'informational';
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문자 bigram 해싱 임베딩 (결정적, L2 정규화) */
|
||||||
|
export function hashEmbedding(text: string, dim = EMBEDDING_DIM): number[] {
|
||||||
|
const s = ` ${normalizeKeyword(text)} `;
|
||||||
|
const vec = new Float64Array(dim);
|
||||||
|
for (let i = 0; i < s.length - 1; i++) {
|
||||||
|
const gram = s.slice(i, i + 2);
|
||||||
|
const h = fnv1a(gram);
|
||||||
|
vec[h % dim] += 1;
|
||||||
|
// 부호 해싱으로 충돌 편향 완화
|
||||||
|
vec[(h >>> 8) % dim] += h & 1 ? 1 : -1;
|
||||||
|
}
|
||||||
|
let norm = 0;
|
||||||
|
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||||
|
norm = Math.sqrt(norm) || 1;
|
||||||
|
return Array.from(vec, (x) => x / norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fnv1a(str: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user