o2o-site-AEO/solution/frontend/src/features/builder/canvas/dataSpec.ts
Mina Choi 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

707 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나".
*
* 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 —
* 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`).
*
* 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다.
*/
import {SECTION_PROMPT_RULES, SECTION_PROMPTS} from '@o2o/shared';
export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
label: string;
/** [예시 넣기] 가 그대로 넣는 값. */
sample: string;
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
task: string;
/** 그 아이템에만 걸리는 금지·형식 규칙. */
rules: string;
/**
* 직접 입력 폼의 칸.
*
* ★ 왜 필요한가: JSON 붙여넣기가 유일한 입구였다. 한 글자만 고치고 싶어도 사장님이
* 중괄호와 쉼표를 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다.
* ChatGPT 를 안 쓰는 사장님은 아예 못 채운다.
* ★ 폼과 JSON 은 **한 값의 두 얼굴**이다 — 진실은 `section.data` 문자열 하나뿐이고
* 폼은 그걸 비춘다. 그래서 JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이
* 다시 쓰인다. 어느 쪽이 최신인지 물을 일이 없다.
*/
fields: ItemField[];
/**
* 항목 안의 목록(정거장 · 시간대 · 들르는 곳).
*
* ★ 이게 없으면 코스·스케줄은 폼으로 못 채운다 — 그 아이템의 알맹이가 전부 이 배열에 있다.
*/
child?: {key: string; label: string; fields: ItemField[]};
}
export interface ItemField {
/** 항목 객체의 키. `@o2o/shared` 의 아이템 타입에 있는 이름 그대로다. */
key: string;
label: string;
/** text = 한 줄 · area = 여러 줄 · number = 숫자 · tags = 쉼표로 끊는 배열 · color = 색 */
type?: 'text' | 'area' | 'number' | 'tags' | 'color';
/** 칸 아래 회색 한 줄. 무엇을 적는 칸인지 애매할 때만 적는다. */
hint?: string;
/** 고르는 값이면 목록. 계절처럼 오탈자가 곧 버그가 되는 칸에 쓴다. */
options?: string[];
/** 두 칸씩 나란히 놓는다 — 짧은 값(연도·시각·분)이 한 줄을 다 먹지 않게. */
half?: boolean;
}
/** 모든 아이템이 공유하는 끝 두 칸 — 출처와 확신. */
const SOURCE_FIELDS: ItemField[] = [
{key: 'verified', label: '확신', type: 'text', options: ['확인', '확인필요'], half: true},
{key: 'source.name', label: '출처 이름', type: 'text', half: true},
{key: 'source.url', label: '출처 주소', type: 'text', hint: '실제로 열리는 공식·기관·언론 페이지'},
];
// ★ 테마 전체 상한이 64KB 다(site_service._THEME_MAX_BYTES). 세 아이템이 각자 다 채우면 거절당하고,
// 거절은 발행 직전에야 드러난다. 그래서 한 섹션당 여기서 먼저 끊는다.
export const SECTION_DATA_MAX_CHARS = 12000;
export interface PromptContext {
storeName: string;
/** 사업장 주소 원문. 여기서 시·군·구를 뽑아 '지역'으로 쓴다. */
location: string;
/** 업종 이름('숙박' · '카페' …). 어떤 손님에게 쓰는 글인지 알려 준다. */
industryLabel: string;
}
/**
* 주소 원문에서 시·군·구 한 조각.
*
* ★ 주소를 통째로 넣으면 모델이 그 동네 안쪽만 뒤진다. 이야깃거리는 시 단위에 있다.
*/
export function regionOf(location: string): string {
const token = location
.trim()
.split(/\s+/)
.find((word) => /[시군구]$/.test(word) && word.length >= 2);
return token ?? location.trim();
}
const PROMPT_RULES = SECTION_PROMPT_RULES;
/**
* 붙여넣으면 바로 답이 나오는 프롬프트.
*
* ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고,
* 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다.
*/
export function buildPrompt(spec: SectionDataSpec, ctx: PromptContext): string {
const store = ctx.storeName.trim() || '(가게 이름을 먼저 입력해 주세요)';
const region = regionOf(ctx.location) || '(주소를 먼저 입력해 주세요)';
const address = ctx.location.trim();
const head = `너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.
[업소] ${store} (${ctx.industryLabel})
[지역] ${region}${address && address !== region ? `\n[주소] ${address}` : ''}
아래 '해야 할 일'에서 [지역] = ${region}, [업소] = ${store}.
`;
return `${head}
${spec.task}
${PROMPT_RULES}${spec.rules}`;
}
export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
songs: {
kind: 'songs',
label: '가요 다방',
sample: JSON.stringify(
{
kind: 'songs',
version: 1,
title: '가요 다방',
subtitle: '이 도시를 노래한 곡들',
items: [
{
title: '금강 나그네',
artist: '이미자',
lyricist: '이병훈',
composer: '박춘석',
year: 1966,
label: '지구레코드',
labelColor: '#d4551f',
story: '군산항을 모티브로 한 곡. 금강 하구와 항구의 이별 정서를 담았습니다.',
connection: '우리 스테이에서 걸어 12분, 내항 부잔교가 이 노래의 배경입니다.',
verified: '확인',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
{
title: '님 떠난 군산항',
artist: '이미자',
labelColor: '#1d3c74',
story: '군산항의 이별 정서를 담은 곡. 발표 연도와 작사·작곡은 아직 확인 중입니다.',
verified: '확인필요',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
{
title: '헤어진 군산항',
artist: '박제연',
labelColor: '#2a6053',
story: '1960년대 군산을 노래한 곡으로 전해집니다. 항구도시의 정서를 담았습니다.',
verified: '확인필요',
source: {name: '세계일보', url: 'https://www.segye.com/newsView/20160329000709'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.songs.task,
rules: SECTION_PROMPTS.songs.rules,
fields: [
{key: 'title', label: '곡 제목'},
{key: 'artist', label: '가수', half: true},
{key: 'year', label: '발표 연도', type: 'number', half: true},
{key: 'lyricist', label: '작사', half: true},
{key: 'composer', label: '작곡', half: true},
{key: 'label', label: '음반사', half: true},
{key: 'labelColor', label: '라벨 색', type: 'color', half: true},
{key: 'story', label: '곡 이야기', type: 'area', hint: '가사는 옮기지 않습니다 — 배경만'},
{key: 'connection', label: '우리 가게와의 연결', type: 'area'},
...SOURCE_FIELDS,
],
},
daily: {
kind: 'daily',
label: '오늘의 한 장',
sample: JSON.stringify(
{
kind: 'daily',
version: 1,
title: '오늘의 한 장',
items: [
{
monthDay: '09-01',
category: '장소',
title: '군산 내항 부잔교',
body: '조수 차가 큰 항구에서 배를 대기 위해 물 위에 띄운 다리입니다. 미곡 반출의 통로이기도 했습니다.',
season: '가을',
tags: ['#군산내항', '#부잔교'],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
monthDay: '09-02',
category: '축제',
title: '군산시간여행축제',
body: '근대문화도시의 정체성을 축제로 풀어냅니다. 밤에 걷는 골목이 낮과 다릅니다.',
season: '가을',
tags: ['#시간여행축제', '#근대문화'],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
monthDay: '09-03',
category: '음식',
title: '이성당',
body: '오래된 빵집 하나가 도시의 이력이 된 자리입니다. 단팥빵 앞의 줄이 그 증거입니다.',
season: '가을',
tags: ['#이성당', '#오래된가게'],
verified: '확인',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.daily.task,
rules: SECTION_PROMPTS.daily.rules,
fields: [
{key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', half: true},
{key: 'category', label: '분류', half: true},
{key: 'title', label: '제목'},
{key: 'body', label: '내용', type: 'area', hint: '두 문장 이내'},
{key: 'season', label: '계절', options: ['봄', '여름', '가을', '겨울'], half: true},
{key: 'tags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다', half: true},
...SOURCE_FIELDS,
],
},
people: {
kind: 'people',
label: '인물 열전',
sample: JSON.stringify(
{
kind: 'people',
version: 1,
title: '인물 열전',
subtitle: '이 도시가 배출한 이름들',
items: [
{
name: '채만식',
aka: '백릉',
years: '19021950',
role: '소설가',
oneLine: '군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.',
imageQuery: '채만식문학관 채만식 초상',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
name: '고은',
years: '1933',
role: '시인',
oneLine: '군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '박화요비',
role: '가수',
oneLine: '군산 출신으로 소개되는 가수입니다.',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.people.task,
rules: SECTION_PROMPTS.people.rules,
fields: [
{key: 'name', label: '이름'},
{key: 'aka', label: '호 · 예명', half: true},
{key: 'years', label: '생몰년', half: true},
{key: 'role', label: '역할'},
{key: 'oneLine', label: '한 줄 소개', type: 'area'},
{key: 'imageQuery', label: '사진 검색어', hint: '사진 주소는 받지 않습니다 — 검색어만'},
...SOURCE_FIELDS,
],
},
chronicle: {
kind: 'chronicle',
label: '시간의 골목',
sample: JSON.stringify(
{
kind: 'chronicle',
version: 1,
title: '시간의 골목',
subtitle: '이 도시를 만든 해들',
items: [
{
year: 1899,
title: '군산 개항',
summary: '항구가 열리며 도시의 성격이 한 번에 바뀝니다.',
place: '군산 내항',
turning: true,
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
year: 1926,
title: '내항 부잔교 축조',
summary: '조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로이기도 했습니다.',
place: '군산 내항 부잔교',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
year: 1937,
title: '『탁류』 연재 시작',
summary: '채만식이 이 도시를 소설의 무대로 세웁니다.',
place: '채만식문학관',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
year: 2010,
title: '새만금 방조제 개통',
summary: '간척으로 해안선이 다시 그려집니다.',
place: '새만금 방조제',
turning: true,
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.chronicle.task,
rules: SECTION_PROMPTS.chronicle.rules,
fields: [
{key: 'year', label: '연도', type: 'number', half: true},
{key: 'place', label: '지금 이 자리', half: true},
{key: 'title', label: '무슨 일'},
{key: 'summary', label: '한 줄 설명', type: 'area'},
{key: 'turning', label: '전환점', options: ['예', '아니오'], hint: '붉은 점으로 표시됩니다'},
...SOURCE_FIELDS,
],
},
reading: {
kind: 'reading',
label: '지역 읽기',
sample: JSON.stringify(
{
kind: 'reading',
version: 1,
title: '군산 읽기',
subtitle: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지',
items: [
{
group: '문학',
title: '채만식 『탁류』',
body:
'일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, ' +
'가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. ' +
'금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 ' +
'이야기가 펼쳐집니다.',
year: 1937,
},
{
group: '섬과 바다',
title: '고군산군도',
body:
'예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, ' +
'그 뒤로 무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 ' +
'있습니다. 섬마다 성격이 달라 하루로는 다 못 봅니다.',
},
],
},
null,
2,
),
task: SECTION_PROMPTS.reading.task,
rules: SECTION_PROMPTS.reading.rules,
/* 출처 칸이 없다 — 이 종류의 출처는 서버가 제목으로 만든 검색 링크다
(`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`). 손으로 적는 칸을 두면
거기 적힌 주소가 그 규칙을 비껴간다. */
fields: [
{key: 'group', label: '갈래', half: true, hint: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활'},
{key: 'year', label: '연도', type: 'number', half: true},
{key: 'title', label: '꼭지 제목'},
{key: 'body', label: '본문', type: 'area', hint: '서너 문장'},
],
},
postcard: {
kind: 'postcard',
label: '오늘의 엽서',
sample: JSON.stringify(
{
kind: 'postcard',
version: 1,
title: '오늘의 엽서',
subtitle: '그대로 붙여 쓰는 한 문장',
items: [
{
line: '부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.',
hashtags: ['#군산', '#내항', '#부잔교'],
place: '군산 내항',
postmark: '군산 內港',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
line: '짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.',
hashtags: ['#군산짬뽕', '#항구도시'],
place: '군산 중앙로',
postmark: '군산 中央',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
line: '단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.',
hashtags: ['#이성당', '#오래된가게'],
place: '이성당',
postmark: '군산 中央',
verified: '확인',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.postcard.task,
rules: SECTION_PROMPTS.postcard.rules,
fields: [
{key: 'line', label: '엽서 문장', type: 'area'},
{key: 'place', label: '장소', half: true},
{key: 'postmark', label: '소인 지명', half: true},
{key: 'hashtags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다 · 3개까지'},
...SOURCE_FIELDS,
],
},
quiz: {
kind: 'quiz',
label: '뒤집어 보는 질문',
sample: JSON.stringify(
{
kind: 'quiz',
version: 1,
title: '뒤집어 보는 질문',
subtitle: '뒤집으면 힌트가 나옵니다',
items: [
{
question: '부잔교는 왜 물 위에 뜨도록 만들었을까?',
hint: '서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.',
topic: '군산 내항',
level: '초등',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
question: '『탁류』의 “탁류”는 무엇을 가리키는 말일까?',
hint: '금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.',
topic: '채만식문학관',
level: '중등',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
question: '고군산군도의 “고(古)”는 왜 붙었을까?',
hint: '지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.',
topic: '고군산군도',
level: '어른',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: SECTION_PROMPTS.quiz.task,
rules: SECTION_PROMPTS.quiz.rules,
fields: [
{key: 'question', label: '질문', type: 'area'},
{key: 'hint', label: '힌트', type: 'area', hint: '정답은 두지 않습니다'},
{key: 'topic', label: '주제', half: true},
{key: 'level', label: '난이도', half: true},
...SOURCE_FIELDS,
],
},
video: {
kind: 'video',
label: '영상',
sample: JSON.stringify(
{
kind: 'video',
version: 1,
title: '영상으로 보기',
subtitle: '사진으로 다 안 보이는 것',
items: [
{
url: 'https://youtube.com/shorts/c2ZdwhaB7S4',
caption: '마당에서 본 저녁 무렵',
verified: '확인',
},
],
},
null,
2,
),
task: `
[해야 할 일]
[업소]의 유튜브 영상 주소를 1~3개 고른다.
· 이미 올라가 있는 영상의 **주소만** 옮긴다. 영상을 만들라는 것이 아니다.
· 손님이 사진으로는 알 수 없는 것을 보여주는 영상이 좋다 — 공간의 크기, 빛, 소리, 가는 길.`,
rules: `
[이 아이템만의 규칙]
· url 은 유튜브만 받는다. watch · youtu.be · shorts 어느 형식이든 된다.
· 다른 사이트 영상 주소를 넣으면 재생되지 않고 링크로만 남는다.
· caption 은 한 줄이다. 영상 제목을 그대로 옮기지 않는다 — 왜 볼 만한지를 적는다.
· 남의 영상을 넣지 않는다. 업소가 올렸거나 업소를 찍은 영상만.`,
fields: [
{key: 'url', label: '유튜브 주소', hint: 'watch · youtu.be · shorts 다 됩니다'},
{key: 'caption', label: '한 줄 설명', hint: '제목 말고 왜 볼 만한지'},
...SOURCE_FIELDS,
],
},
event: {
kind: 'event',
label: '소식',
sample: JSON.stringify(
{
kind: 'event',
version: 1,
title: '소식',
items: [
{
title: '가을 평일 2박 할인',
kind: '이벤트',
startDate: '2026-09-01',
endDate: '2026-10-31',
summary: '평일 2박 이상 예약하시면 1박 요금의 20%를 빼 드립니다.',
howTo: "예약 시 요청사항에 '가을 평일' 이라고 적어 주세요.",
postUrl: 'https://www.instagram.com/p/xxxxxxxxxxx/',
verified: '확인',
},
{
title: '10월 정기 휴무 안내',
kind: '공지',
startDate: '2026-10-14',
endDate: '2026-10-16',
body: '설비 점검으로 사흘간 쉽니다. 이 기간 예약은 받지 않습니다.',
verified: '확인',
},
],
},
null,
2,
),
/*
* ★ 이 아이템만 프롬프트가 다르다. 나머지는 지역을 리서치해 오는 것이지만
* 소식은 **이 가게에서 지금 실제로 하는 일**이다 — 모델이 알 수 없고, 지어내면
* 손님이 없는 행사를 보고 찾아온다. 그래서 "찾아라" 가 아니라 "옮겨 적어라" 다.
*/
task: `[해야 할 일]
아래 붙여넣은 인스타그램 게시물·공지 글을 소식 JSON 으로 **옮겨 적는다.** 찾지 말고 옮긴다.
[여기에 원문을 붙여넣으세요]
[스키마]
{ "kind":"event", "version":1, "title":"소식", "items":[
{ "title":"소식 제목", "kind":"이벤트|공지",
"startDate":"2026-09-01", "endDate":"2026-10-31",
"summary":"카드에 보일 한두 문장", "body":"긴 본문(공지처럼 여러 줄일 때만)",
"howTo":"어떻게 참여하나",
"postUrl":"https://www.instagram.com/p/...",
"verified":"확인|확인필요" } ] }`,
rules: `
[이 아이템만의 규칙]
· 원문에 없는 날짜·혜택·조건을 만들지 않는다. 안 적혀 있으면 그 필드를 뺀다.
· 기간이 지난 소식은 넣지 않는다 — 끝난 행사를 걸어 두면 손님이 헛걸음한다.
· postUrl 은 붙여넣은 원문의 주소 그대로다. 없으면 넣지 않는다.
· 사장님이 직접 옮긴 것이므로 verified 는 "확인" 이다.
`,
fields: [
{key: 'title', label: '제목'},
{key: 'kind', label: '종류', type: 'text', options: ['이벤트', '공지'], half: true},
{key: 'startDate', label: '시작일', type: 'text', hint: '2026-09-01', half: true},
{key: 'endDate', label: '종료일', type: 'text', hint: '2026-10-31', half: true},
{key: 'summary', label: '요약', type: 'area', hint: '카드에 보이는 한두 문장'},
{key: 'body', label: '본문', type: 'area', hint: '공지처럼 여러 줄일 때만'},
{key: 'howTo', label: '참여 방법', type: 'area'},
{key: 'postUrl', label: '원문 주소', type: 'text'},
...SOURCE_FIELDS,
],
},
itinerary: {
kind: 'itinerary',
label: '추천 일정',
sample: JSON.stringify(
{
kind: 'itinerary',
version: 1,
title: '추천 일정',
subtitle: '며칠 묵느냐에 따라',
items: [
{
name: '개항장 골목 한 바퀴',
duration: '반나절',
audience: '처음 온 손님',
why: '걷는 거리가 가장 짧고, 군산이 어떤 도시였는지 먼저 보게 됩니다.',
startTime: '09:30',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 12, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관', latitude: 35.9908197098, longitude: 126.7121231556},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관', latitude: 35.9877369536, longitude: 126.7083826065},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당', latitude: 35.9870443303, longitude: 126.7111681428},
],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬까지 다녀오는 1박 2일',
duration: '1박 2일',
audience: '차를 가져온 손님',
why: '첫날은 도심, 이튿날은 바다. 하루씩 성격이 다릅니다.',
days: [
{
label: '첫째 날',
startTime: '14:00',
stops: [
{name: '경암동 철길마을', minutes: 40, moveMinutes: 15, searchQuery: '군산 경암동 철길마을', latitude: 35.9813520474, longitude: 126.7362330582},
{name: '째보선창', minutes: 40, moveMinutes: 12, note: '해 지는 시간에 맞추면 좋습니다.', searchQuery: '군산 째보선창', latitude: 35.9875345, longitude: 126.7199875},
],
},
{
label: '둘째 날',
startTime: '09:30',
stops: [
{name: '고군산군도 선유도', minutes: 180, moveMinutes: 45, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도', latitude: 35.8102049733, longitude: 126.4124052922},
{name: '장자도', minutes: 60, moveMinutes: 12, searchQuery: '군산 장자도', latitude: 35.8101, longitude: 126.3973},
],
},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `
[해야 할 일]
[업소]에서 출발하는 추천 일정을 3~5개 만든다. **반나절 · 1박 2일 · 2박 3일** 을 섞는다.
· 하루짜리는 stops 만 쓴다. 여러 날이면 days 로 날짜를 나눈다.
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.`,
rules: `
[이 아이템만의 규칙]
· 순위를 매기지 않는다. rank 같은 칸은 없다 — 어느 일정이 1위인지는 정할 일이 아니다.
· duration 은 "반나절" · "1박 2일" · "2박 3일" 중 하나로 적는다. 목록이 이 값으로 갈린다.
· season 은 **꼭 필요할 때만** 적는다. 적으면 그 계절에만 손님 화면에 나간다
(벚꽃·해수욕처럼 계절을 타는 일정에만).
· 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
· latitude · longitude 는 **아는 곳만** 적는다. 지도에 번호 핀이 찍히는 값이다 —
지어내면 엉뚱한 동네에 핀이 찍히고, 비워 두면 그 칸은 시간표에만 선다(그게 낫다).
· 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.`,
fields: [
{key: 'name', label: '일정 이름'},
{key: 'duration', label: '기간', options: ['반나절', '1박 2일', '2박 3일'], half: true},
{key: 'startTime', label: '출발 시각', hint: 'HH:MM · 하루짜리만', half: true},
{key: 'audience', label: '누구에게 맞는 일정', half: true},
{
key: 'season',
label: '계절',
half: true,
options: ['봄', '여름', '가을', '겨울'],
hint: '적으면 그 계절에만 나갑니다',
},
{key: 'why', label: '왜 이 일정인가', type: 'area'},
...SOURCE_FIELDS,
],
child: {
key: 'stops',
label: '정거장',
fields: [
{key: 'name', label: '장소'},
{key: 'minutes', label: '머무는 시간(분)', type: 'number', half: true},
{key: 'moveMinutes', label: '오는 데 걸리는 시간(분)', type: 'number', half: true},
{key: 'searchQuery', label: '지도 검색어'},
{key: 'note', label: '한 줄', type: 'area'},
],
},
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
return SECTION_DATA_SPEC[sectionType];
}