킹서버에서 방금 발행한 사이트도 sitemap.xml 에 죽은 옛 호스트(w4ai.o2o.kr)로 나가고
있었다 — Google Search Console 스윕에 URL_NOT_IN_SITEMAP 으로 잡혀서 발견했다.
원인: solution-worker 는 기동할 때마다(=배포할 때마다) `--seed-assets` 로 prerender 를
한 번 돌리는데, 이 경로는 payload 를 안 읽어서 오리진을 얻을 곳이 없다 — 그래서
findBakedOrigin() 이 out/s/ 를 훑어 **가장 먼저 발견한** 사이트의 baked canonical 을 그대로
쓴다. readdirSync 순서는 보장이 없고, SITE_PUBLIC_HOST 를 web4ai.o2osolution.ai 로 옮긴 뒤
한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 그 자리에 걸리면, 통합 sitemap.xml
전체가 죽은 옛 호스트로 통째로 구워진다 — 오늘 새로 발행한 사이트(sono, 자기 페이지의
canonical 은 정상)까지 사이트맵에서는 옛 호스트로 나갔다.
고침: 처음 찾은 것 대신 **가장 최근에 구워진(mtime 최신)** 사이트의 오리진을 쓴다 —
최근에 발행된 사이트일수록 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다.
검증: tsc --noEmit 통과. 킹서버 solution-worker 재시작 후 sitemap.xml 재확인 예정.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GSC_ENABLED=1 로 킹서버에 켜져 있었는데도 search-console 스윕(10분마다)이
ValueError(GSC_CONFIG_MISSING) 로 매번 조용히 실패하고 있었다 — 로그엔 잡혀 있던
BATCH_FAILED 문구만 남고 원인은 안 보이는 종류였다.
원인: search_console_settings.load_settings() 는 GSC_CREDENTIALS_FILE(컨테이너 경로)을
읽는데, docker-compose.yml 이 GSC_CREDENTIALS_HOST_FILE(.env 의 호스트 경로)을 컨테이너로
마운트하는 줄도, GSC_CREDENTIALS_FILE 을 채워주는 줄도 없었다 — 두 변수 다 문서(.env.example
· SEARCH_CONSOLE.md)에만 있고 컴포즈에 실제로 연결된 적이 없었다. 킹서버엔 서비스 계정 키
파일(/home/o2oadmin/.secrets/search-console.json)도 이미 있었다 — 배선만 빠져 있었다.
고침: solution-backend 서비스에 볼륨 마운트 + 고정 컨테이너 경로 env 추가. 호스트 경로가
비어 있으면(GSC 를 안 켠 환경) /dev/null 을 대신 마운트해 컴포즈 문법이 안 깨지게 했다.
검증: docker compose config --quiet 통과. 킹서버에서 GSC_CONFIG_MISSING 없이 배치가 도는지는
배포 후 로그로 재확인.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feature/social-post 를 feature/site-features-and-mockup 에 머지한 뒤 실제 배포·테스트로
잡았다 — 두 브랜치가 각자 독립적으로 LLM 공급자 리팩터(services/llm/) 이전/이후 상태로
갈라져 있어 SNS 쪽 코드가 옛 인터페이스를 그대로 참조하고 있었다.
- gemini_text.generate_social_post: 모듈 최상단에 call·DEFAULT_MODEL·extract_text 를
services.llm.gemini 에서 들여오지 않아 실제 게시 시도가 전부 NameError/AttributeError 로
죽는 상태였다(테스트도 gemini_text.call 을 monkeypatch 하려다 AttributeError). json 모듈도
import 가 빠져 있었다.
- common/enums.py JobType: SOCIAL_DRAFT=8 이 기존 ROLLBACK=8 과 값이 겹쳐 있었다 — Python
enum 은 값이 같으면 뒤 멤버가 앞 멤버의 별칭이 되므로, 워커의 핸들러 등록표에서
HANDLERS[8] 이 SOCIAL_DRAFT 핸들러로 먼저 채워지고 ROLLBACK 은 "이미 등록됨" 판정으로
덮어써지지 않았다 — 롤백 요청이 SNS 초안 핸들러로 잘못 라우팅돼 KeyError('post_id') 로
죽었다. SOCIAL_DRAFT=9, SOCIAL_POST=10 으로 재배정. init.sql 의 job_type 주석도 갱신.
검증: test_social.py 14 passed, test_rollback.py 4 passed, 전체 백엔드 763 passed(기존
LLM 공급자 전환 관련 무관 실패 44건 제외 동일).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.
- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
(line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
빌드 스크립트 추가(다른 세션 작업)
테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
## 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>
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.
- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션
검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
주신 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>
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.
## 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>
Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발 등) sites.thumbnail_url 이
계속 비어 있어 발행된 사이트도 카드가 아이콘으로 떨어졌다. 그 대신 빌더가
이미 쓰는 대표 사진(place_photos)을 한 번이라도 발행한 줄에 채운다 —
내 사이트 목록과 랜딩 쇼케이스 둘 다 같은 규칙.
EssentialInfoSection 예약 공지 라벨에서 채널명(NOL)을 뺀다.
사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).
검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다.
날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인.
사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리.
- Google 클라이언트·배치·DB·Teams 알림 모듈 분리
- 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가
- ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신
검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
대표 지적 여섯을 한 번에 고친다.
- **재발행이 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>
배포 뒤 대표 지적 넷을 한 번에 고친다.
- 사진 중계(`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>
세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(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>
앞 커밋(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): 누가 빌더에서 슬러그 `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-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>
`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>
머지는 됐는데 이미지를 안 구워 번들이 머지 이전 것이었다 — 소스에는 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>
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.
- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선
검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면
`[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 —
화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다.
- ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다)
- ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이
musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다
- docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가.
자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다
- 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다
- 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다
검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_select() 는 조회 실패를 빈 목록으로 삼키도록 짜여 있었는데, DB 계층 기본값이
raise_error=True 라 그 처리가 실행될 기회조차 없이 예외가 먼저 터졌다. 실측:
place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 — 노래 한 칸이
빠진 화면 대신 아무것도 못 보는 화면이 나갔다.
- snapshot.py: _local_contents · _site_places · _select 의 DB 조회에
raise_error=False 를 명시 — 실패는 그 칸만 비우고 나머지는 그대로 그린다.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
/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
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
'오늘의 엽서'는 미리 써 둔 문구만 보여줬다 — 손님이 사진 고르고 직접 한마디를 써서
카드를 만들고 저장·공유까지 하게 바꿨다. 군산 읽기는 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
앱 자격증명(THREADS_*)이 없으면 카드를 통째로 숨겼다. 근거는 "누를 수 없는 버튼을 세우지
않는다" 였는데, **이 기능을 만든 사람조차 "연동 버튼이 아예 안 보인다" 고 했다**(2026-09-14).
만든 사람이 못 찾으면 사장님은 더더욱 못 찾는다 — 숨기는 것과 "아직 준비 중" 은 다른 말이고,
화면은 그 둘을 구별해 말해야 한다.
- 자리는 늘 보이고 **버튼만 비활성**이다. "연동 준비 중입니다. 열리면 여기서 계정을 연결합니다."
- 통째로 접는 경우는 하나만 남겼다 — 상태를 아직 못 읽었을 때(로그인 직후 한순간).
그건 '준비 안 됨' 이 아니라 '모름' 이라 다르게 다뤄야 한다
검증: frontend tsc·eslint 통과. 번들에 새 문구가 실린 것과 `connection_enabled=false` 에서
비활성 상태로 그려지는 것을 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `user × provider` 하나다(표도 그렇게 생겼다)
— 버튼이 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 사장님 지적:
"여기 내 사이트에 sns 연동 하나 두고 연동해두고, 사이트 발행 후 게재하는 형식으로".
화면이 데이터 모양을 그대로 말해야 한다 — 연결은 한 번, 게재는 사이트마다다.
- `GET /v1/social/account` 신설: 연결 상태만 준다. 사업장을 고르지 않아도 답할 수 있어야 하는
값인데, 지금까지는 `/place/{id}` 안에만 있어서 사이트를 하나 고르기 전에는 물어볼 수 없었다
- features/social/SocialConnectionCard: '내 사이트' 목록 위의 연동 카드
(@핸들 · [연결] · [다시 연결] · [연결 해제]). 앱 자격증명이 없으면 **아무것도 안 그린다** —
누를 수 없는 버튼을 세우면 사장님에게는 고장난 화면이고 우리에게는 문의가 된다
- SocialPanel(발행 화면)에서 연결·해제 버튼 제거. 대신 계정이 없으면 "막다른 문구"가 아니라
**갈 곳**을 알린다 — [내 사이트] 로 보낸다. 연결 전에도 소개글 복사는 된다
- docs/SOCIAL.md: 사장님 흐름을 '연결(한 번) / 게재(사이트마다)' 로 다시 씀
검증: SNS 테스트 14건 통과 · frontend tsc·eslint 통과. 로컬에서 임시 자격증명으로 카드가
켜지는 것과 인가 URL 조립을 확인하고 값을 되돌렸다(지금은 connection_enabled=false 로 안 뜬다)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
자동 게재가 되려면 사장님이 자기 Threads 계정을 연결해야 한다. 연결 코드(인가 URL·코드 교환·
장기 토큰·암호문 저장·해제)는 이미 있었는데, **실제로 연동하려면 무엇을 해야 하는지**가
어디에도 없었고 실패했을 때 이유를 볼 방법도 없었다.
★ 콜백이 예외를 통째로 삼키고 있었다. 화면에는 `?social=failed` 만 뜨고 우리도 원인을 모른다 —
키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다. 연결이 안 되는데
로그에 아무것도 없는 것은 이 레포가 가장 싫어하는 종류다.
→ 서버 로그에는 남기고 화면에는 안 내보낸다(OAuth 응답·state 에 자격증명이 들어 있다).
남기는 것은 예외 종류와 우리가 만든 사유 문자열뿐 — 토큰·code·state 는 찍지 않는다.
사장님이 인가를 취소한 경우도 고장과 구별되게 따로 남긴다.
- router/v1/social/oauth: 실패 로그 추가(`[social] 계정 연결 실패: …`)
- tests: 가짜 Threads 서버로 **연결 왕복 전체**를 검증한다(코드 교환 → 장기 토큰 → debug_token
권한 검증 → 저장 → 해제). 실제 연결은 Meta 앱 등록이 끝나야 시험할 수 있는데, 그때 실패하면
우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 안 된다 — 우리 쪽 왕복은 먼저 못 박는다.
지키는 것 셋: 저장된 것은 암호문이다 · 권한 검증을 건너뛰지 않는다 · 해제하면 토큰이 지워진다
- docs/SOCIAL.md '연동 준비': Meta 앱 콘솔에서 할 일(제품 추가·리디렉션 URI·권한 둘·
★심사 전에는 테스터로 추가된 계정만 인가된다) · 사장님 클릭 흐름 · 실패 시 로그 읽는 법
- .env.example: SOCIAL_*·THREADS_*·ALIMTALK_* 항목과 각각의 "비면 무엇이 꺼지는가"
★ 로컬만으로는 연결을 끝까지 검증할 수 없다 — Meta 는 콜백 URI 를 https 로만 받는다.
터널로 https 주소를 만들거나 킹서버에서 확인해야 한다. 문서에 적어 뒀다.
★ `SOCIAL_TOKEN_SECRET`(Fernet) 이 없으면 연결 기능 자체가 꺼진다. 이 키를 잃으면 저장된
토큰을 복호화할 수 없어 전원 재연결이다 — 그 사실도 문서에 적었다.
검증: SNS 테스트 14건 통과(신규 1 — 연결 왕복). 로컬에서 키만 넣고 앱 자격증명이 없는 상태를
확인: connection_enabled=false 로 버튼이 안 뜨고, 강제로 불러도 409 SOCIAL_CONNECTION_DISABLED
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/approve/ 블록에 Referrer-Policy·no-store·noindex 를 달아 뒀는데 **응답에 하나도 안 붙었다**
(실측 2026-09-14: 200 은 뜨는데 헤더만 없다). `try_files` 의 폴백은 내부 리다이렉트라 요청이
이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는 이 블록의 add_header 가
적용되지 않는다. 승인 링크의 nonce 가 Referer 로 새고 검색 색인에 남을 수 있는 상태였다.
- `rewrite ^ /__spa-fallback.html break` 로 바꿨다. break 는 같은 블록에 머물러 헤더가 붙는다.
승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다 — 곧바로 셸을 준다
- site.conf.example 과 로컬 site.conf 를 같이 고쳤다. ★ 운영 서버의 site.conf 는 gitignore 라
example 을 고쳐도 따라오지 않는다 — 배포 때 이 블록을 손으로 옮겨야 한다
검증: `curl -D -` 로 Referrer-Policy: no-referrer · Cache-Control: no-store ·
X-Robots-Tag: noindex, nofollow 세 줄 확인. 승인 페이지가 빌더 셸로 뜨는 것도 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.
★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
행위를 한다. 아래 결정이 전부 여기서 나왔다.
승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.
★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.
플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.
- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔
밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
분 단위 정밀도가 필요 없다
검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>