세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(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>
707 lines
29 KiB
TypeScript
707 lines
29 KiB
TypeScript
/**
|
||
* 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 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: '1902–1950',
|
||
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];
|
||
}
|