Commit Graph

68 Commits

Author SHA1 Message Date
29af37f158 [chore] site/mockup: 목업 스크립트·벤더 번들 정리 — stay2/stay3 빌드 도구 추가
patch_stay.py·inject.js/css·audit-all.mjs 갱신, 벤더 번들 교체(index-D9cPXCE3 →
index-D4jgGFP4 등), stay3 백업 스냅샷과 stay2/stay3 빌드 스크립트(build_stay2.py·
build_stay3.py) 및 원본 템플릿(tpl-story·tpl-creative) 추가. 옛 번들은
vendor/retired/ 에 보관.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:02:11 +09:00
dbae2d4f35 [feat] solution/backend: LLM 공급자를 OpenAI 기본값으로 전환, Perplexity 실비용 계측 추가
## 1. Gemini -> OpenAI 공급자 추상화

Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를
OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다.

- services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult
- services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가
- services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16):
  gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is
  supported") — 아예 안 보낸다.
- services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로
  둘 중 하나를 고른다.
- gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고
  내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경.
  단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다.
- config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가.

## 2. Perplexity 실비용 계측 추가

OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니,
services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서
실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리
`{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로
가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸
라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다.

- services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다
  — 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다)
- external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py·
  external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가

실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상),
OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006).

검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP
계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→
소개문·FAQ→발행까지 라이브로 왕복 확인.

## 3. site/EssentialInfoSection.tsx

미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:30:11 +09:00
b76144746b [feat] solution/frontend,site: 파비콘을 PNG 로 — 발행본·앱 양쪽에 건다
주신 32x32 PNG 를 브랜드 자산으로 넣고 앱과 발행본이 같이 쓴다.
SVG 는 뒤에 남겨 둔다 — PNG 를 못 읽는 자리는 없지만, 고해상도 탭에서는 SVG 가 낫다.
브라우저는 앞의 것부터 보고 처리할 수 있는 것을 고른다.

- public/brand/favicon-w4a.png (신규, 32x32)
- site/seo/head.ts · frontend/root.tsx: PNG 를 먼저, SVG 를 뒤에

검증: site vitest 86건 통과 · tsc(site·frontend) 통과.
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:32:03 +09:00
079c93a62a [feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것)

COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던
것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다.

- services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규)
- postgres-init/migrations/0013_job_progress.sql + init.sql
- 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선,
  orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason)
- docs/GENERATION_FLOW.md (신규)

## 2. 발행된 사이트만 색인한다

실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로
구글에 실려 있었다는 뜻이다.

head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다.
"색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더
미리보기만 눌러도 draft 로 구워진다.

- seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow`
- 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어
  거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다

## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다

ba90a19 의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 —
새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15).

- Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로)
- place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은
  계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다
- ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다

## 4. 발행본 파비콘

발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의
공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다.

검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과.
백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701 과 같은 자리).
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:29:27 +09:00
4cd756108d Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-15 17:21:40 +09:00
217d0853bc [fix] solution: 썸네일 저장소 없이도 내 사이트·랜딩 카드에 그림을 채운다
Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발 등) sites.thumbnail_url 이
계속 비어 있어 발행된 사이트도 카드가 아이콘으로 떨어졌다. 그 대신 빌더가
이미 쓰는 대표 사진(place_photos)을 한 번이라도 발행한 줄에 채운다 —
내 사이트 목록과 랜딩 쇼케이스 둘 다 같은 규칙.

EssentialInfoSection 예약 공지 라벨에서 채널명(NOL)을 뺀다.
2026-09-15 17:19:07 +09:00
a6ddeccdff [feat] solution: 맛집 카드 사진 필터링·지도검색 연결
사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).

검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
2026-09-15 16:28:39 +09:00
820a2e9e35 feat(site): 오늘의 날씨 문구 로딩과 조건별 순환 추가
목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다.

날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인.
2026-09-15 16:23:53 +09:00
f2087aad5e feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리
상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다.

사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
2026-09-15 16:12:16 +09:00
9773bc0496 [fix] solution: 엽서 자리·판 색·푸터 표기, 재발행이 노래를 다시 만들지 않게
대표 지적 여섯을 한 번에 고친다.

- **재발행이 Suno 를 다시 부르던 것**(`song_service.ensure_song`). `build_service` 가
  `publish=True` 마다 이 함수를 불렀는데 가드가 없었다 — 내용이 하나도 안 바뀐 재발행에도
  Gemini 가사 1회 + Suno 작곡 1회가 그대로 나갔고, 다섯 번 누르면 유료 호출 다섯 번에
  `place_songs` 행 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로 나머지는 돈만 쓴다.
  → READY 곡이 있으면 건너뛴다. 일부러 다시 만드는 길은 SONG 잡(`force=True`)으로 남긴다
- **엽서 쓰기 자리**를 계절별 축제 바로 앞으로(시연본과 같게). 목록 맨 뒤라 FAQ 보다도
  아래였다. 축제가 없는 사이트는 종전대로 맨 뒤
- **엽서가 모바일에서 화면을 밀어내던 것** — 그리드 칸의 기본 `min-width:auto` 가
  사진 레일의 최소 너비(실측 390px 화면에서 2,056px)를 그대로 받아 칸이 밖으로 나갔다.
  `min-w-0` 과 캔버스 비율 고정(`aspect-square h-auto`). 실측 넘침 0px · 캔버스 358×358
- **가요 다방 판이 한 색이던 것** — 폴백 팔레트를 시연본(`/s/stay`)이 쓰는 23색 그대로
  옮겼다(대표: "그냥 정해놔 · /s/stay 보고 맞춰라"). 목록을 새로 짜면 시연본과 갈린다
- **푸터에 만든 곳 표기** — `AI O2O의 Web4Ai로 만든 사이트입니다.`(o2osolution.ai 링크).
  발행본(`SiteFooter`)과 시연본(`patch_stay.py`) 둘 다
- **에디터 캔버스의 푸터·모바일 탭바 제거** — 발행본이 iframe 안에서 둘 다 이미 그린다
  (`SiteFooter` · `MobileTabBar`). 바깥에 한 벌 더 그려 두 번 서 있었다
- **시연본 자동재생 끔**(`inject.js`, 대표: "클릭하고 나서 되니까 사람들이 에러로 보잖아").
  세 번째 뒤집기고 이번 이유는 다르다 — 정책을 못 이겨서가 아니라 정책에 걸린 모습이
  고장으로 읽혀서다. 함수는 남기고 호출만 뺐다. 제품 렌더러엔 원래 없다

검증: tsc·eslint 통과(site·frontend), site 79 passed. 실제로 구워서 확인 —
섹션 순서 `…오시는 길 → 엽서쓰기 → 계절별 축제 → …`, 푸터에 o2osolution.ai 링크,
모바일 가로 넘침 0. 킹서버 실측: 재발행 안 한 사이트(joyyy·bbbb)도 중계로 공유 버튼이 뜬다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:22:26 +09:00
f0c4d5f413 [fix] solution: 엽서·가요 다방 손질 — 재굽기 전 사이트도 공유되게 사진을 중계한다
배포 뒤 대표 지적 넷을 한 번에 고친다.

- 사진 중계(`backend/router/v1/media/relay.py`, `/v1/image/relay`) — 굽는 쪽이 사진을
  우리 자리로 옮기는 건 **다시 굽는 사이트에만** 적용된다. 아직 재발행 안 한 사이트는
  캔버스가 오염된 채라 저장·공유가 막혀 있다. 같은 오리진으로 바이트를 흘려보내 그 사이를
  메운다. `postcard-canvas.loadImage` 가 CORS 실패 때만 두 번째로 탄다 —
  이미 우리 자리에 있는 사진은 첫 시도에서 끝난다.
  ★ 열린 프록시가 되지 않게: https · 호스트 allowlist · 이미지 타입 · 8MB ·
    리다이렉트 후 호스트 재검사 · IP 주소 거절. 실측: pstatic 사진 158KB 통과,
    http·목록 밖·127.0.0.1·169.254.169.254 전부 거절
- 엽서 미리보기 360px (320 → 240 은 작다 하셔서 그 사이로)
- 엽서 사진 목록에 슬라이더가 없던 것 — `.slider-viewport` 마크업만 손으로 찍어서
  embla 가 안 붙어 있었다(그건 스크립트 붙기 전의 기본 상태다). 진짜 `Carousel` 로 바꾼다
- 가요 다방 판이 전부 주朱색이던 것 — **실측 67곡 중 63곡에 `labelColor` 가 없다.**
  모델이 공통 규칙 2("확인 안 된 값은 뺀다")를 색에까지 적용한다. 색은 사실이 아니라
  디자인 값이다: 프롬프트에 그렇게 못 박고, 비었을 때는 제목 해시로 레코드 라벨색 8종에서
  고른다(무작위가 아니다 — 하이드레이션이 어긋나면 안 되고, 새로고침마다 바뀌어도 안 된다)

tsc·eslint 통과(site), site 79 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:51:53 +09:00
6b9e01d876 [feat] solution: 지역 읽기 섹션 · 엽서 공유를 풀고 · 기존 사이트는 자산 주소만 갈아 끼운다
세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.

① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
   새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
   그 종류를 몰랐던 것과 같은 사고).
   · shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
     (프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
   · backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
     제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
   · site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
   · 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다

② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
   `tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
   (실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
   · `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
     payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
   · 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
   · `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
   · 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")

③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
   재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
   렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
   · `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
   · `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
     새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
   · 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다

문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.

검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:31:19 +09:00
a1a416ca4a [fix] site,docs: 굽기 제외는 stay 하나로 — stay2 는 실제 사이트가 쓰고 있다
앞 커밋(7425cb4)이 stay2·stay3 까지 막아 버터브루(slug=stay2) 재굽기가 실패했다
(실측 2026-09-15: `✗ stay2 — 슬러그 'stay2' 는 시연본이…`, 프리렌더 exit 1).
시연본은 `/s/stay` 하나다(mockup/README 2.1) — 자산도 `/s/stay/` 밑으로 자급자족이라
다른 사이트를 다 구워도 안 깨진다.

- prerender.ts: PROTECTED_SLUGS 기본값 `stay`
- AGENTS.md: 같은 내용

tsc·eslint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:28:41 +09:00
7425cb409e [fix] site,docs: 목업 슬러그는 굽지 않는다 — 발행 하나에 /s/stay 시연본이 통째로 덮였다
실측(2026-09-15): 누가 빌더에서 슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자
프리렌더가 기동하며 그 payload 로 `/s/stay` 를 구웠다 — 손으로 만든 유일본이 사라졌다
(캐치프레이즈 100개 · 미니 플레이어 · 날씨 문구 · 주입분 전부). 백업에서 되돌렸다.
"payload 가 없으면 안 굽는다"(AGENTS.md 함정 1)는 목업을 못 지킨다 — payload 가 생기는
순간 덮인다.

- prerender.ts: PROTECTED_SLUGS(기본 stay,stay2,stay3 · PRERENDER_PROTECTED_SLUGS 로 덮어씀).
  그 슬러그는 굽지 않고 실패 보고서를 쓴다 — 사장님 화면에 사유가 뜨고 목업은 남는다
- AGENTS.md: 함정 1에 이 경로와 payloads-mockup-hold 를 적는다

tsc·eslint 통과 · vitest 75 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:22:53 +09:00
93bd5435ac [feat] solution/site,docs: 엽서 쓰기를 발행본에 넣는다 — 그리기 규칙은 한 파일로
시연본에만 주입 스크립트로 있던 엽서 쓰기를 발행본 컴포넌트로 옮겼다(2026-09-14 대표:
"웹빌드 해도 엽서쓰기 나오게"). 사진이 있는 사이트면 섹션이 나간다.

★ 캔버스가 남의 도메인 사진에 오염되면 저장·공유가 SecurityError 로 죽는다 — 미리보기는
  보이는데 내보내기만 막히는, 눈으로는 못 찾는 종류다. 실측: 발행본 사진은 네이버 CDN 에 있고
  그쪽은 Access-Control-Allow-Origin 을 주지 않는다(curl -I 확인). CORS 로 먼저 받아 보고
  실패하면 CORS 없이 받아 **미리보기만** 세우고 저장·공유 단추를 감춘다.

- lib/postcard-canvas.ts(신규): 그리기만 한다. 여백 56 · 우표 칸 190 · 최대 4줄은 시연본과 같은 값
- sections/items/PostcardMakerSection.tsx(신규): 사진 고르기·입력(4줄 제한)·공유/저장
- HomePage: 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 직접 낸다
- package-lock.json: playwright 선언에 맞춰 잠금 갱신(어제 커밋에서 lock 을 안 맞춰 npm ci 가 죽었다)
- DEVLOG: 근본 해결(사진을 우리 오리진으로 옮기기)은 아직 안 했다고 적어 둔다

검증: tsc 통과 · 발행본(/s/statata)에서 사진·문구·우표·소인·서명 줄이 그려지고,
      외부 사진이라 저장·공유 대신 안내문이 뜬다(스크린샷 확인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:16:24 +09:00
3b4c5c98bc [chore] package.json,site/mockup: 점검 도구(playwright)를 선언하고 도는 자리를 적는다
`audit-all.mjs`·`rails-test.mjs` 가 playwright 로 도는데 **package.json 어디에도 선언이 없었다** —
내 PC 의 node_modules 에만 있어서, 새로 클론한 사람은 `npm install` 을 해도 점검을 못 돌린다.

- package.json: devDependencies 에 playwright ^1.63.0 (실제 쓰던 판)
- README: 점검은 **개발 PC에서 주소를 주고** 돈다 — 킹서버엔 node 가 없다.
  도커에 넣지 않는 이유도 적었다(점검은 배포물이 아니라 개발 도구다)

검증: node audit-all.mjs https://web4ai.o2osolution.ai/s/stay → 45개 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:07:23 +09:00
2824dedd42 [fix] site/mockup: 번들을 머지본으로 갈아 끼우고, 점검을 그날 결정에 맞춘다
머지는 됐는데 이미지를 안 구워 번들이 머지 이전 것이었다 — 소스에는 StorySection·DailySection 이
있는데 번들에는 없어, payload 에 값이 있어도 화면이 비었다. 목업은 자기 번들을 들고 다니므로
렌더러를 고치면 **vendor/ 의 css·js 한 벌만** 갈아 끼우면 된다(다시 만드는 게 아니다).

점검 6건이 실패했는데 그중 5건은 코드가 아니라 **점검이 낡은 것**이었다. 2026-09-14 결정:
오늘의 한 장은 탭을 세우지 않고(HIDE_TABS), 도시 엽서 4장은 손님이 쓰는 엽서로 바뀌었고,
군산 읽기는 무작위 5~6꼭지 카로셀이다(신문 조판은 걷어냈다). 점검만 옛 기준을 보고 있었다.

- vendor/: index-DQreaXOf.css · index-D9cPXCE3.js 로 교체, 옛 번들은 vendor/retired/
- audit-all.mjs: 군산 읽기(재료·화면·출처) · 오늘의 한 장 숨김 · 엽서 쓰기로 항목 정리.
  선택자도 실제 id(#w4d-reading-panel)로 — 옛 #w4d-reading 을 보느라 0꼭지로 세고 있었다
- README: 번들 갈아 끼우는 절차와 "점검 기준이 결정을 따라가야 한다" 표 추가

검증: node audit-all.mjs 45개 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:01:28 +09:00
dd3ad86715 Merge branch 'main' into feature/crawler 2026-09-14 19:45:25 +09:00
a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00
bdc69a80f2 Merge branch 'feature/stay-mockup-postcard-reading' 2026-09-14 17:09:51 +09:00
0da9b92423 [feat] site/mockup: 시연본 갱신 — 주변 맛집 31곳·국가유산 야행·차 동선 분기·번들 최신화
/s/stay 시연본에 오늘 쌓인 변경을 한꺼번에 반영한다. 원본 8곳이 전부 467m 안이라
'걸어서 10분 이상' 탭의 맛집 칸이 비었던 것, xlsx 대조로 사이트에 없던 축제(국가유산
야행)가 빠져 있던 것, 차로 온 손님도 걸어온 손님과 같은 동선(짐 맡기고 저녁에 복귀)을
따르던 것, 번들이 2026-09-09 판이라 숙박 예약 섹션이 아예 안 그려지던 것 — 전부 발견한
순서대로 고쳤다.

- patch_stay.py: 날짜 값(UPDATED_AT) 한 곳으로 모음 · 날씨 문구 케이스당 1개→5개
  무작위 · '오늘의 엽서' 탭 비움(엽서 쓰기 섹션으로 대체) · 주변 맛집 8→31곳 +
  사진 없는 집에 시연 전용 사진 채움 · 군산 국가유산 야행 추가(날짜 미확정이라
  기간 값은 안 넣음) · 오늘의 한 장 탭 숨김 · 예약 섹션 켬 · head 메타의 날짜도
  같이 갱신 · 번들을 최신 빌드로 교체하고 `<body class="site">` 보정(빠지면 카로셀
  overflow 가 죽어 가로 스크롤이 생겼다) · 군산 읽기/엽서 카로셀에 쓸
  embla-carousel UMD 번들 도입
- build_itinerary.py: 차로 온 손님의 마지막 날은 복귀 칸 없이 마지막 정거장에서
  끝나도록 동선을 가름(도보 뼈대에서 복귀 칸만 떼어 파생 — 두 벌로 안 둔다)
- build_story.py: 진희경은 익산 출생·군산에서 통학 — "군산 출신"으로 뭉뚱그리지 않게 정정
- fetch_restaurants.py · fill_restaurant_photos.py · restaurants.json ·
  restaurant-photos.json · img/mirror/*: TourAPI 로 받은 맛집 31곳과, 사진 없는
  집에 채운 시연 전용 사진(공공누리 아님 — 제품으로는 안 옮긴다)
- vendor/: embla-carousel.umd.js 추가, 번들을 index-C3fFNl3r.css·index-DDxwteyn.js
  로 교체하고 옛 파일은 vendor/retired/ 로 보관
- audit-all.mjs · audit_schedule.py · SCHEDULE.md: 위 변경에 맞춘 점검 항목·산출물 갱신
- AUTOPLAY.md: 새로고침 직후 배경음악 자동재생을 시도한 것과 실측값 — 사람 입력 없이는
  브라우저가 막아서 안 된다는 결론 기록

검증: `python3 patch_stay.py` 정상 종료, 킹서버 반영 후 md5 대조.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:07:01 +09:00
민헌
8a09af6599 [feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내
COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다.
생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다.
공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 —
답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다.

- common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외
- services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가
  겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다)
- copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만
- place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만)
- SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다
- faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게
- prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다)
- shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD ·
  llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다
- site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다
- frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다
- postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON,
  0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다
- docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md

백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다
site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과
로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0
0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
2026-09-14 17:05:43 +09:00
0500ab3a3f [feat] site/mockup: /s/stay 에 엽서 쓰기 · 새 군산 읽기를 넣는다 — 탭 전환 버그 수정
'오늘의 엽서'는 미리 써 둔 문구만 보여줬다 — 손님이 사진 고르고 직접 한마디를 써서
카드를 만들고 저장·공유까지 하게 바꿨다. 군산 읽기는 xlsx 원본 대비 2줄짜리 항목이
많아 부실했고(gunsan_365_story_db.xlsx 365행은 52개 주제를 10가지 각도로 복제한
것 — 그대로 실으면 같은 말이 반복된다), 3~5줄로 다시 쓰고 확인필요 배지 대신
네이버 검색 링크로 바꿨다. 그 과정에서 군산 읽기 탭에서 다른 탭(시간의 골목)을
누르면 화면이 사라지는 버그를 잡았다 — React 가 no-op setState 를 건너뛰면서
직접 건드린 DOM 속성(hidden·aria-selected)이 안 돌아오던 것.

- inject.js: 엽서 쓰기 섹션(사진 선택 → 캔버스 카드 → 저장/공유), 4줄 캡+글자 단위
  줄바꿈, 공유는 이미지+링크만(캡션 중복 안 보냄) · 군산 읽기 탭 5~6개 무작위 노출 ·
  탭 전환 시 React 미관리 속성(style.display)으로 전환하고 클릭된 탭에 상태를 직접
  강제해 no-op setState 버그 제거
- inject.css: 엽서 쓰기 버튼·썸네일 카로셀 스타일, 군산 읽기 확인필요 배지 제거,
  768~1023px 구간에서 하단 탭바가 푸터 마지막 줄을 덮는 것 막는 보정
  (SiteFooter.tsx 는 md: 768px 부터, MobileTabBar.tsx 는 lg: 1024px 부터 기준을
  잡아 그 사이가 비었다 — 렌더러 쪽 근본 수정은 별도)
- build_reading.py: 34꼭지 재작성(2줄짜리 21개를 3~5줄로), 출처를 네이버 검색
  링크로 통일, verified 필드 제거
- build_daily.py · dump_text.py: 위에서 뺀 verified 필드를 읽던 자리 방어 처리
- README.md: 1부(T7 군산 읽기 · T8 엽서 쓰기)를 실제 구현 가이드로 다시 씀 —
  히스토리·인용 없이 무엇을 만들어야 하는지만, 엽서는 AI/스키마 작업이 아니라
  UI 그대로 옮기면 되는 것임을 명시

검증: Playwright 로 탭 재전환·엽서 줄바꿈 재현 확인. 킹서버 반영 후
`docker exec o2o-web4ai-solution-site md5sum` 로컬-킹 일치 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:01:40 +09:00
민헌
b2c8bb033e [feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만
숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`·
`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다.
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이
그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다.
SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로
입력 문자열 검색 결과를 준다.

- services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이
  재시도, resolved 가 우리 place_id 가 아니면 실패로 본다
- services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기,
  업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만
- services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속
- services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로
- site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는
  키워드가 있을 때만(빈 태그를 만들지 않는다)
- config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다
- docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md

pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다)
site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과
실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` +
keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
2026-09-14 14:38:24 +09:00
ab158be16f Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-11 14:40:07 +09:00
548ec5c0c1 Merge branch 'fix/sitemap-noindex' — 사이트맵·목록에서 noindex 페이지 제외
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:09:16 +09:00
4216a05fca [fix] site: 사이트맵·목록·llms.txt 에서 noindex 페이지 제외 — 목업 복제본이 /s/stay 를 검색에서 밀어낸 것
/s/stay 가 "스테이머뭄" 구글 검색에서 통째로 빠졌다. out/s/ 에 목업·백업(stay2 · stay3 ·
stay.old)이 운영본과 제목·본문이 같은 채(단어 87%) 각자 자기를 canonical 로 가리키고 있었고,
사이트맵·/s 목록이 디렉토리를 훑어 이들을 전부 구글에 제출했다 — 같은 글 여러 벌 중 구글이
하나만 고른다. 목업 셋은 운영 볼륨에서 noindex 로 바꿨고(2026-09-11), 이 커밋은 그런 페이지가
다시 제출되지 않게 한다.

- seo/directory.ts: readBakedNoindex — 구운 HTML 의 robots 메타를 읽는다. 슬러그 이름(.old)으로
  거르지 않는다 — 페이지 자신의 선언이 유일한 출처다
- prerender.ts writeRootMachineFiles: noindex 페이지를 사이트맵·/s 목록·루트 llms.txt 에서 뺀다.
  남겨 두면 서치콘솔이 "제출된 URL 에 noindex" 를 계속 띄운다

vitest directory.test.ts 8 passed · tsc·eslint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:08:48 +09:00
8a2964ebf7 [feat] solution: 추천 일정 카드에 출발지(업소명·출발 시각) 한 줄을 얹는다 2026-09-11 14:07:17 +09:00
46e4d091a4 [fix] solution/site: 헤더 플레이어를 /s/stay 목업과 같은 것으로 — 주입 CSS·마크업을 그대로 옮긴다
발행본에 붙인 플레이어가 목업(/s/stay)과 다른 물건이었다. 목업은 카세트 · 현재 곡명 ·
재생 · 목록(음표) 네 자리에 크림색 패널·LP판·EQ 막대인데, 새로 만든 쪽은 lucide 아이콘
하나에 흰 드롭다운이었다. 같은 제품에서 두 화면이 서로 다른 플레이어를 갖고 있었다.

목업 화면은 사장님 확인을 여러 번 거친 것이다 — 카세트를 앞에 세운 것("전혀 뮤직플레이어
같지가 않아"), 목록 아이콘을 햄버거에서 음표로 바꾼 것("메뉴 버튼이랑 헷갈림"), 셔플 제거,
자동재생 제거("그럼 자동 빼"), 모바일 폭(좌우 8px). 새로 그리면 그 합의가 통째로 풀린다.
→ `scripts/mockup/inject.css` 를 **값 그대로** 옮기고, `inject.js` 의 DOM 을 같은 구조로 짰다.

- site/sections/song-player.css: 목업 주입 CSS 이식(w4d-* 클래스·--tpl-* 변수 그대로).
  앞으로는 목업이 아니라 이 파일이 단일 출처다 — 목업은 손으로 만든 한 장이고
  발행되는 모든 사이트는 이 컴포넌트를 쓴다
- SongPlayer: 카세트(재생 중 릴 회전) · 현재 곡명 · 재생/멈춤 · 목록 네 자리.
  패널은 fixed 로 헤더 아래에 붙이고(sticky 헤더라 스크롤해도 자리가 같다) 좁은 화면에서는
  좌우 8px 만 남기고 펼친다. 줄은 LP판 + 제목/가수 + 시간, 재생 중인 줄은 판 대신 EQ 막대
- 시작 곡만 무작위, 그다음은 순차(목업과 같은 규칙). ★ 무작위는 마운트 후에 고른다 —
  서버 렌더에서 뽑으면 HTML 과 하이드레이션 결과가 달라져 화면을 통째로 다시 그린다
- 패널은 여전히 hidden 여닫이다. 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어
  크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다)

검증: tsc --noEmit · eslint · vitest 55 passed. 재굽기 후 /s/스테이머뭄-99a887f8 에서
w4d-mini/tape/now/play/toggle · panel(head·head-n·item·mark·disc-sm·eq·info·t·sub·time)
· lyrics-box 전부 확인, 번들 CSS 에 w4d 규칙 포함 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:36:16 +09:00
38bb4be9e5 [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno
/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:21:16 +09:00
703f831273 [fix] site/scripts: 끼니 간격·체류 대비 이동을 규칙으로 — 섬 코스가 103분 만에 두 번째 상
사장님 지적("1시간 30분 걸려서 갔는데 짧게 머문다")을 전수로 다시 셌다.
대상은 선유도가 아니었다(150분). 섬에서 돌아오는 길의 **끼니 간격**이다 —
늦은 점심 15:47 종료, 저녁 17:30 시작으로 103분이다. 늦은 점심 창(≤15:00)과
저녁 창(≥17:30)이 둘 다 합법이라 규칙 14종을 그냥 통과했다. 창 밖에 규칙이 따로 필요했다.

- build_itinerary.py: MEAL_GAP=180. `_window_of()` 가 앞 끼니 종료 + 3시간 뒤로 다음 끼니
  창을 민다. 섬 2박3일 둘째 날이 쉼 168분으로 늘고 저녁이 18:47 로 밀렸다
- build_itinerary.py audit(): 규칙 15(끼니 간격) · 16(볼거리는 편도 이동보다 오래 머문다)
  추가. 16은 **끼니를 뺀다** — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다
- audit_schedule.py 신설: 구워진 payload 를 표로 펴고, 만들 때 규칙이 **안 보는** 논리
  12가지(L0~L11)를 센다 → SCHEDULE.md
- README: 규칙 14종 → 16종. 장소 대장 프롬프트에 "멀리 있는 곳 근처 식당도 넣어라" 추가 —
  규칙만 고치면 다음 지역에서 또 난다
- README: 일정 프롬프트 위치 정정. shared/section-prompts.ts 가 아니라
  frontend/.../canvas/dataSpec.ts:556 이고, video·event·itinerary 셋은 **빌더에만** 있어
  서버 생성 잡이 만들 줄 모른다(daily 와 같은 사고). 그 프롬프트가 모델에게 시각 계산을
  시키고 있는 것도 적었다 — 시연본 첫 판이 망가진 직접 원인이다
- README: 목업을 지울 때 1부를 docs/ 로 옮기라는 안내

검증: 규칙 16종 위반 0건 · audit_schedule 21/21 검수완료 · audit-all.mjs 34/34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:10:16 +09:00
b86a055278 [docs] site/scripts: 지침을 README 한 장으로 합치고 일회용 스크립트를 지운다
README · SECTIONS · PROMPTS · REVIEW 넷으로 흩어져 있어서 무엇을 먼저 볼지 알 수 없었다.
특히 "실제 구현하는 사람이 어떤 파일을 열어야 하나" 가 어디에도 없었다 —
목업 폴더가 사양서라는 것과, 코드는 shared 계약 3개 + backend 서비스 4개 + site/src
섹션에서 고친다는 것.

- README.md 한 장으로 합침. 1부 실제 구현(참고할 파일 · 섹션 22개 현황 · 할 일 T1~T6 ·
  프롬프트 전문 6종) / 2부 목업 고치기(굽기·배포·규칙 14종·주입분) / 3부 공통(보고 규칙 ·
  틀렸던 방식 · 파일 목록 · 지울 것 · 남은 판단)
- SECTIONS.md · PROMPTS.md · REVIEW-2026-09-11.md 삭제 — 내용은 전부 README 로
- 일회용 probe 스크립트 24개 삭제. 남긴 것은 audit-all · rails-test · kingcheck 셋
- scripts/mockup/.gitignore 신설 — 중간 산출물(stay-payload-new.json)

검증: node audit-all.mjs 34/34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:58:21 +09:00
ea1daae576 [fix] site/scripts: 시연본을 레포 안에서 자급자족하게 — 다른 기계에서 흰 화면이던 것
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로
가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다.
번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
실측: 구운 html 이 /assets 를 63곳 가리키고 있었다.

- img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다
- patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고,
  레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다
- audit-all.mjs: 히어로 사진 검사를 새 경로로
- SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고
  PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가
- README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:51:46 +09:00
a134805b3f [docs] site/scripts: 섹션 22개별 "목업은 무엇으로 만들었고 제품은 무엇으로 만드나"
빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기려는 사람이,
화면을 보고 역산해야 하는 상태였다. 시연본은 payload 가 없는 목업이라
"화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있는데 그 간격이 어디에
얼마나 있는지 적힌 곳이 없었다.

섹션마다 값이 오는 payload 자리 · 계약 유무 · 프롬프트 위치 · 목업이 손으로 한 것을
적고, 계약에 아예 없는 셋(히어로 캐치프레이즈 · 자작곡 플레이어 · 일정 규칙)은
추가할 타입과 프롬프트 초안까지 넣었다.

- SECTIONS.md 신설: 22개 섹션표 · 새로 만들어야 할 6가지 · 프롬프트 작성 규칙 · 검증
- README.md: 제품으로 옮기는 사람을 SECTIONS.md 로 보낸다. 감사 항목 수 33 → 34 정정

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:42:33 +09:00
f0b5c444bc [chore] site/scripts: /s/stay 시연본 자산 — 사진 45장·음원 5곡
2026-09-07 에 목업 자산이 통째로 404 가 났을 때, 복구한 곳은 서버가 아니라
`stay-mockup` 워크트리였다. 지금 이 사진과 음원은 이 맥과 킹서버 볼륨에만 있다 —
컨테이너를 지우면 같이 간다. 자산 커밋을 따로 떼는 이유는 나중에 히스토리에서
덩치를 걷어낼 일이 생겨도 이 커밋 하나만 건드리면 되기 때문이다.

- img/ 28장: 히어로·소개·객실(야놀자 등록본 A동 12·B동 10)·정거장
- img/people/ 17장: 위키백과 문서 사진. 자유 라이선스가 확인된 것만이고
  저작자 표시를 화면에 남긴다(라이선스 조건이라 지우면 안 된다)
- audio/ 5곡: 사장님이 직접 만든 곡. 헤더 미니 플레이어가 파일로 재생한다
  (프로토타입이라 CDN 을 쓰지 않는다 — nginx `^~ /s/` 가 Range 206 으로 준다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:29:06 +09:00
43ba33dd65 [feat] site/scripts: /s/stay 시연본 조립 도구 일습 — 일정 생성기·주입분·검증
이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고
자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을
이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다.
`build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다.

- build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h,
  1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가
  **빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에
  있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW)
- build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다
  (이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼)
- patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고
  payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다
- inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환·
  헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다
- audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수
- README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지"
- PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:28:52 +09:00
25f7e5c0c8 Merge branch 'feature/stay-parity' 2026-09-10 16:15:29 +09:00
868127a69a [fix] solution/site: 예약 목업의 날짜를 달력으로 — 14칸 스트립을 걷어낸다
날짜를 한 줄로 펼쳐 놓으니 예약 화면으로 안 읽혔다. 두 주만 보이는 것도 문제였다 —
손님이 다음 달을 잡으려면 방법이 없었다.

- 월 단위 그리드로 바꿨다. 요일 머리(일~토)를 두고 1일 앞을 빈 칸으로 채워 **열을 맞춘다** —
  달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다
- 이전·다음 달. 오늘이 든 달이 하한이고 앞으로 두 달까지다(MONTH_SPAN).
  ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤까지 열어 두면 손님은 그 날짜가
    열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다
- 토·일은 요일 머리와 함께 구분한다. 장식이 아니라 주말 요금이 붙는 날이라는 정보다
- **고를 수 없는 날은 지난 날짜뿐이다.** '마감'·'잔여' 는 여전히 만들지 않는다 —
  우리는 그 값을 모르고, 지어내면 손님이 그걸 보고 다른 날을 고른다

★ 서버 렌더 게이트는 그대로다. '오늘' 을 브라우저에서 정하므로 정적 HTML 에는 달력이
  굽히지 않는다 — 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는 일이 없다.

site tsc·eslint 통과 · vitest 51 passed(SSR 이 날짜를 굽지 않는지 보는 기존 검사 포함).
2026-09-10 15:24:21 +09:00
a7fcc14e7d [feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
2026-09-10 15:19:23 +09:00
328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00
ee51d89e83 [fix] solution/backend,site: 지역 이야기가 영영 안 생기던 것 + 자체 홈페이지 자동 등록
스테이,머뭄으로 실제 발행해 시안(/s/stay)과 대조한 결과에서 나온 셋이다.

★ 지역 이야기 다섯이 통째로 비어 있었다 (섹션 13 → 18)
  `has_stories()` 가 "이 지역에 이야기가 있나" 를 `kind IS NOT NULL` 로 판정했다. 그런데
  kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를
  채웠다(AREA_KIND). 그래서 주변정보가 한 건이라도 들어온 지역은 이야기가 0건이어도
  "이미 있다" 로 판정돼 생성이 영영 건너뛰어졌다. 잡은 성공으로 끝나고 로그도 조용해서
  생성기가 없는 것처럼 보였다. STORY_KINDS 를 명시해서 고친다.
  실측(전북 군산시): 고친 뒤 54건 생성(가요 8·인물 10·연표 12·엽서 12·퀴즈 12),
  발행본 섹션 13 → 18, 본문 6,598자 → 10,798자.

★ 업소 자체 홈페이지를 아무도 등록하지 않고 버리고 있었다
  네이버 지역검색 응답의 `link` 가 업체 홈페이지인데(external/naver.py 머리주석이 "채널 URL
  발견에 쓸 수 있는 부수입" 이라 적어 뒀다) 채널로 등록하는 코드가 없었다. 숙박은 자체
  홈페이지 보유율이 3업종 중 가장 높고(표본 25건 중 19건), 네이버 플레이스가 fact 를 3건밖에
  주지 않는 업소에서는 **그게 유일한 공개 출처**다. discover_official_site 로 등록·확정한다.
  - 추측이 아니다. 네이버가 그 업소 레코드에 달아 둔 값이고 동일 업소 판정(pick_match)을
    통과했을 때만 쓴다 — discover_naver_place 와 같은 근거라 자동 확정한다
  - 수집 금지 호스트(인스타·OTA)도 **등록은 한다**. 크롤은 static_html 의 _DENY_HOSTS 와
    robots 가 막지만, 공식 채널·sameAs 로는 유효한 사실이다
  - ★ 검색어에 `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 행정구역
    코드('52군산시')라 후보가 0건이 된다(실측). `naver_place_lookup.region_hint` 로 쓴다
  - collect_service 에 남아 있던 옛 표 이름(place_links.DBType) 한 곳도 같이 고쳤다 —
    스키마 재편 때 놓친 자리이고, 실행되는 순간에만 NameError 로 터진다

★ 숙박 예약 분기 복구 — `booking: isLodging ? StayBookingSection : BookingSection`
  64ce467 에서 사라져 숙박 발행이 절대규칙 3 대조에 걸려 통째로 막혀 있었다.

검증: site tsc·eslint·vitest 51 passed. 백엔드 pytest 529 passed / 52 failed —
**52건은 이 변경 전 main 에서도 같은 수로 실패한다**(기준선 확인). 스테이,머뭄 실발행으로
공식 채널·예약 채널 노출과 18섹션 확인.
2026-09-10 13:48:44 +09:00
b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00
c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.

**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
  (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
  기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
  우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
  한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
  그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
  사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.

**자리 가르기**
    area_*        = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
    site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
  같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
  배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
  읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
  **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
  UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.

검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:31 +09:00
64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00
7bbeb068a8 Merge branch 'feature/stay-booking'
숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.

충돌 4건 해결:
- seo/verify.ts STRUCTURAL: main 이 unitCode·numberOfRooms 를 이미 넣었다. main 쪽을 살리고
  "사람이 읽는 unitText 는 넣지 않는다" 만 주석으로 얹었다(같은 결론에 각자 도달했다)
- pages/HomePage.tsx: import 목록만 갈렸다 — StorySection(main) · StayBookingSection(feature)
  둘 다 필요하다
- backend/collect_service.py: 테넌트 제거로 _finish 인자가 company_id → owner_user_id 로
  바뀌었다. main 시그니처를 따르고 _store_booking_link 를 그 앞에 둔다.
  place.verified_by · _add_link 시그니처는 그대로라 예약 링크 경로는 손댈 것이 없었다
- docs/DEVLOG.md: 양쪽 새 항목을 날짜 내림차순으로 합쳤다

검증: site tsc·eslint·vitest 51 passed · frontend tsc·eslint 통과 ·
백엔드 이미지 재빌드 후 collect_service import + LinkChannel.NAVER_BOOKING=7 확인.
2026-09-09 10:42:51 +09:00
0b33f035ef [feat] solution/site: 예약 안내 안에 날짜·시간 목업 — 연동 없이 화면에서만 돈다
예약 흐름을 눈으로 보려고 StayBookingDemo 를 예약 안내 섹션 안에 넣었다. 날짜(2주) ·
도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고 전화로 잇는다. 재고 조회도 접수도
결제도 없다 — PRODUCT.md 6절은 그대로다.

목업이라도 지킨 선:
- 마감/잔여를 만들지 않는다. 모르는 값을 그럴듯하게 그리면 목업이 아니라 거짓말이다
- 시간 후보는 체크인 fact(16:00)에서 시작한다. fact 가 없으면 시간 선택을 내지 않는다 —
  확인된 값과 어긋나는 선택지는 목업에도 두지 않는다
- 요금은 요금표·JSON-LD 와 같은 출처(unitBaseRate)를 쓴다. 한 페이지가 두 값을 말하지 않게
- 확인 화면은 "접수됐다"고 쓰지 않는다(사실이 아니다). 경고문도 두지 않는다(2026-09-09 결정)

★ 날짜는 브라우저에서 만든다(mounted 게이트). 서버에서 구우면 발행 시각의 날짜가 정적
HTML 에 박혀, 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는다 — 화면은 멀쩡하고 기계가
읽는 값만 틀리는 종류다. SSR 은 안내 한 줄만 내보낸다.

구조화 데이터·llms.txt 는 그대로다(availability 없음). 목업을 AI 에게 예약 창구로 소개하면
그때부터는 목업이 아니다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다.

tsc·eslint 통과, vitest 51 passed(신규 4). 발행본 재굽기 후 /s/<slug> 확인.
2026-09-09 10:19:07 +09:00
f2dad65792 [fix] site,deploy: 발행본 목록의 정본 주소를 /s 로 — 끝 슬래시 제거
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.

색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.

- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
  `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는
  늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다
- prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가
  이 값 하나를 쓴다
- directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신

검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측
`/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/<slug>`→200 · `/s/<slug>/`→200 ·
`/nope`→200 앱 셸(변화 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
2026-09-08 13:02:11 +09:00
66f81f3631 [feat] solution/backend,site: 예약 버튼을 네이버 예약 화면으로 — 검색 화면이 뜨던 것
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고
눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.

주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다
(실측 2026-09-08, place 1273971279):
  naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685
★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도
  주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다.

- LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석)
- collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소
- naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다
- collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place
  와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다)
- site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스).
  화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다
- site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이
  "네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게)
- 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정)

tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인.
2026-09-08 10:52:36 +09:00
00e13bca7f [feat] solution/site,shared: 발행본을 /s/stay 목업에 맞춘다 — 잃어버린 CSS 토큰 복원 + 섹션 마크업 이식
목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.

- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
  .h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
  ★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
    히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
  목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
  ★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
    "어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
  사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
  글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
  ★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
    room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
    stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
  ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
  ★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다

검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
2026-09-08 09:53:19 +09:00
d498d36ccf [feat] solution/site,frontend,backend: 숙박 예약 구성 — 요금·인원·창구를 한자리에
숙박으로 발행하면 서버 기본표가 booking 섹션을 켜는데, 발행본이 읽는 fact
(reservation_required·reservation_channel)가 **숙박 스키마에 없다**. 그래서 펜션·민박의
"실시간 예약" 섹션에는 전화번호 한 줄만 남았다 — 요금도 인원도 취소 규정도 없었다.
숙박은 예약이 곧 매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의
대부분인데, 그 답의 근거가 페이지에 없으면 AI 는 OTA 후기에서 추측한다.

★ 예약을 처리하게 만든 게 아니다. 재고도 결제도 갖지 않는다(PRODUCT.md 6절) — 날짜
선택기·예약 폼을 그리지 않았고, "여기서 결제되지 않는다" 를 화면 맨 앞과 llms.txt 에
명시했다. 없는 기능을 흉내내면 손님은 예약한 줄 알고 안 온다.

- site/sections/StayBookingSection: 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) /
  예약 전 확인 8항목. 근거가 없으면 섹션째 안 나간다
- site/lib/derive: stayBookingView() 가 그릴지 말지까지 판단한다 — 내비·탭이 같은 함수를
  본다(각자 판단하면 눌러도 아무 일 없는 탭이 생긴다). 예약 채널은 문의 목록에서 뺀다
- site/seo/jsonld: unitBaseRate() 를 요금 숫자의 단일 출처로. makesOffer(객실별 1박) ·
  potentialAction(확정 채널만) 추가. availability 는 안 넣는다 — 빈 방을 모른다
- site/seo/llms: 숙박 ## 예약 블록을 위쪽에. 아래에만 있으면 답에 안 실린다
- frontend/industryData, backend/site_payload: 기본 섹션명 "실시간 예약" → "예약 안내".
  실시간 예약을 하지 않는데 제목이 그렇게 말했다(두 파일은 parity 테스트가 묶는다)
- site/seo/verify: 데모 payload 가 원래 굽히지 않던 오탐 둘을 고쳤다(main 에서 재현) —
  속성의 &amp; 이스케이프 때문에 화면에 있는 이미지 URL 을 못 찾던 것, ㎡ 의 단위 코드
  MTK 를 본문에서 찾던 것. 되돌린 사본에서도 못 찾으면 그대로 실패다

tsc·eslint 통과, vitest 43 passed(신규 21). 데모 재굽기 성공 → /s/moonlight-stay-jeju 200.
백엔드 pytest 는 venv 가 없어 미실행 — 섹션표 parity 는 같은 방식으로 손대조했다.
2026-09-07 15:27:29 +09:00