세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(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>
680 lines
28 KiB
TypeScript
680 lines
28 KiB
TypeScript
/**
|
||
* 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가".
|
||
*
|
||
* ★ 왜 shared 인가
|
||
* 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site).
|
||
* 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정).
|
||
* 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`).
|
||
*/
|
||
|
||
/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */
|
||
export type DataVerified = '확인' | '확인필요';
|
||
|
||
export interface DataSource {
|
||
name: string;
|
||
url?: string;
|
||
}
|
||
|
||
export interface SongItem {
|
||
title: string;
|
||
artist?: string;
|
||
lyricist?: string;
|
||
composer?: string;
|
||
year?: number;
|
||
label?: string;
|
||
labelColor?: string;
|
||
story?: string;
|
||
connection?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface DailyItem {
|
||
monthDay: string;
|
||
category?: string;
|
||
title: string;
|
||
body?: string;
|
||
season?: string;
|
||
tags?: string[];
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface CourseStop {
|
||
order?: number;
|
||
name: string;
|
||
minutes?: number;
|
||
note?: string;
|
||
searchQuery?: string;
|
||
}
|
||
|
||
export interface CourseItem {
|
||
name: string;
|
||
duration?: string;
|
||
startsFrom?: string;
|
||
stops?: CourseStop[];
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface ScheduleSlot {
|
||
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
|
||
time: string;
|
||
title: string;
|
||
place?: string;
|
||
minutes?: number;
|
||
note?: string;
|
||
searchQuery?: string;
|
||
}
|
||
|
||
export interface ScheduleItem {
|
||
name: string;
|
||
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
|
||
audience?: string;
|
||
season?: string;
|
||
slots?: ScheduleSlot[];
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface PeopleItem {
|
||
name: string;
|
||
/** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */
|
||
aka?: string;
|
||
years?: string;
|
||
role?: string;
|
||
oneLine?: string;
|
||
/**
|
||
* 사진 검색어. 사진을 못 구했을 때 어디서 찾을지만 남긴다 —
|
||
* 모델이 지어낸 주소를 그대로 링크하면 깨진 사진이 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
|
||
*/
|
||
imageQuery?: string;
|
||
/**
|
||
* 사진. 있으면 필름 프레임에 들어가고, 없으면 지금처럼 이니셜 활자가 그 자리를 지킨다 —
|
||
* 빈 회색 상자를 만들지 않는다(LocalPlace.imageUrl 과 같은 규칙).
|
||
* ★ 우리가 찾아 붙이는 사진이 아니다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것 중
|
||
* **상업적 이용을 허용하는 저작권 유형만** 수집 단계에서 담는다
|
||
* (`services/external/tour_places.py` 의 `_IMAGE_OK`).
|
||
*/
|
||
imageUrl?: string;
|
||
/**
|
||
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||
*/
|
||
imageCredit?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface ChronicleItem {
|
||
/** 연도가 없으면 레일 끝으로 밀린다 — 순서를 지어내지 않는다. */
|
||
year?: number;
|
||
title: string;
|
||
summary?: string;
|
||
place?: string;
|
||
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
||
imageUrl?: string;
|
||
/**
|
||
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||
*/
|
||
imageCredit?: string;
|
||
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
|
||
turning?: boolean;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
/**
|
||
* 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글.
|
||
*
|
||
* ★ 왜 일력(daily)과 따로 있나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는
|
||
* 무리다, 하루에 하나씩밖에 안 알려 주니까")
|
||
* 일력은 오늘·어제·내일 **세 장만** 화면에 선다. 도시를 소개하는 그릇으로는 작다.
|
||
* 이쪽은 갈래(문학·섬과 바다·역사·장소·음식과 생활)로 묶은 더미에서 매번 몇 개만 뽑아 낸다.
|
||
* ★ 이미 제 자리를 가진 것은 여기 넣지 않는다 — 인물·가요·축제·연표·명소·맛집.
|
||
* 같은 이야기를 두 번 세우면 페이지만 길어진다(프롬프트 규칙으로도 막는다).
|
||
*/
|
||
export interface ReadingItem {
|
||
/** 갈래 이름. 카드 위 이름표로만 쓴다 — 갈래별로 묶어 세우지 않는다(뽑기는 갈래를 안 가린다). */
|
||
group?: string;
|
||
title: string;
|
||
/** 서너 문장. 두 문장짜리는 카드가 한 줄로 접혀 빈 카드처럼 보인다(2026-09-14 대표 지적). */
|
||
body: string;
|
||
/** 연도를 댈 수 있는 꼭지만. 없으면 이름표에 연도를 안 붙인다. */
|
||
year?: number;
|
||
verified?: DataVerified;
|
||
/**
|
||
* 출처. **모델이 적지 않는다** — 제목으로 찾아가는 검색 링크를 서버가 붙인다
|
||
* (`services/grounding/story.py`). 개별 문서 주소를 짐작해 적으면 없는 문서로 이어진다.
|
||
*/
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface LiteratureItem {
|
||
workTitle: string;
|
||
author?: string;
|
||
/** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */
|
||
year?: string;
|
||
genre?: string;
|
||
spineColor?: string;
|
||
background?: string;
|
||
whyHere?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface PostcardItem {
|
||
line: string;
|
||
hashtags?: string[];
|
||
place?: string;
|
||
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
|
||
postmark?: string;
|
||
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
||
imageUrl?: string;
|
||
/**
|
||
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||
*/
|
||
imageCredit?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface QuizItem {
|
||
question: string;
|
||
/** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */
|
||
hint?: string;
|
||
topic?: string;
|
||
level?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
/**
|
||
* 추천 일정 — 반나절 · 1박2일 · 2박3일.
|
||
*
|
||
* ★ 왜 하나로 합쳤나
|
||
* 한때 코스가 셋이었다 — 반나절 산책(course) · 여행 스케줄(schedule) · 계절별 추천 하루(planner).
|
||
* 축이 다르다고 나눠 뒀는데 실제 데이터를 보니 **같은 장소를 세 번 나열**하고 있었다
|
||
* (째보선창이 셋 중 둘에, 초원사진관도 마찬가지). 손님에게는 "군산에서 어디를 도나"
|
||
* 하나의 질문이고, 다른 건 **며칠짜리인가** 뿐이다. 그래서 축을 기간으로 바꿨다.
|
||
* ★ 순위를 매기지 않는다. 어느 코스가 1위인지는 우리가 정할 일이 아니다.
|
||
*/
|
||
export interface ItineraryDay {
|
||
/** "첫째 날". 없으면 순번으로 만든다. */
|
||
label?: string;
|
||
/** 그날 나서는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */
|
||
startTime?: string;
|
||
stops?: PlannerStop[];
|
||
}
|
||
|
||
export interface ItineraryItem {
|
||
name: string;
|
||
/** "반나절" · "1박 2일" · "2박 3일". 이 값이 목록을 가르는 축이다. */
|
||
duration?: string;
|
||
audience?: string;
|
||
/** 왜 이 일정인가. 고르는 근거 한 문장. */
|
||
why?: string;
|
||
/**
|
||
* 계절. 적으면 그 계절에만 손님 화면에 나간다(`inSeason`).
|
||
* 겨울에 벚꽃 코스를 권하지 않기 위한 것이지, 목록을 계절로 가르기 위한 게 아니다.
|
||
*/
|
||
season?: string;
|
||
/** 하루 이상이면 이 배열을 쓴다. */
|
||
days?: ItineraryDay[];
|
||
/** 하루짜리는 days 없이 이것만 써도 된다 — 한 덩이로 본다. */
|
||
stops?: PlannerStop[];
|
||
startTime?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface VideoItem {
|
||
/** 유튜브 주소. watch · youtu.be · shorts · embed 넷 다 받는다. */
|
||
url: string;
|
||
caption?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
/**
|
||
* 이벤트·소식 — 사장님이 인스타에 올리는 그때그때의 행사.
|
||
*
|
||
* ★ 인스타를 읽어 오지 않는다. 계정 페이지는 로그인·봇 탐지 뒤에 있고, 우회는 영구 금지다
|
||
* (DECISIONS 1-1). 사장님이 **본인 게시물을 옮겨 적고 원문 주소를 단다** —
|
||
* 손님은 우리 요약을 읽고, 정확한 것은 원문에서 본다.
|
||
* ★ 날짜는 'YYYY-MM-DD'. 끝 날짜가 없으면 상시로 본다.
|
||
*/
|
||
export interface EventItem {
|
||
title: string;
|
||
/** '이벤트' 면 진행 여부 배지가 붙고, '공지' 면 그냥 공지다. 없으면 이벤트로 본다. */
|
||
kind?: '이벤트' | '공지';
|
||
startDate?: string;
|
||
endDate?: string;
|
||
/** 무엇을 주는 행사인가 — 한두 문장. 카드에 보이는 요약이다. */
|
||
summary?: string;
|
||
/** 긴 본문. 공지는 지켜야 할 것이 여러 줄이라 요약 한두 문장으로는 안 된다.
|
||
* 카드에서는 잘려 보이고 모달에서 전부 펴진다 — 잘리는 건 화면뿐, 문서에는 다 있다. */
|
||
body?: string;
|
||
/** 어떻게 참여하나. "예약 시 요청사항에 '9월 이벤트' 라고 적어 주세요" 같은 것. */
|
||
howTo?: string;
|
||
/** 원문(인스타 게시물) 주소. */
|
||
postUrl?: string;
|
||
imageUrl?: string;
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
export interface PlannerStop {
|
||
name: string;
|
||
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
|
||
minutes?: number;
|
||
/** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */
|
||
moveMinutes?: number;
|
||
note?: string;
|
||
/** 정거장 사진. 없으면 승차권은 지금처럼 활자만으로 선다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
||
imageUrl?: string;
|
||
searchQuery?: string;
|
||
/**
|
||
* 지도에 핀을 찍을 좌표. **둘 다 있어야** 쓴다 — 하나만 있으면 없는 것으로 본다.
|
||
* 사장님이 손으로 적을 값이 아니다(발행 때 지오코딩으로 채울 자리). 없으면 그 칸은
|
||
* 핀 없이 시간표에만 선다 — 틀린 핀을 찍느니 안 찍는다(LocationSection 과 같은 규칙).
|
||
*/
|
||
latitude?: number;
|
||
longitude?: number;
|
||
}
|
||
|
||
export interface PlannerItem {
|
||
name: string;
|
||
/** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */
|
||
season?: string;
|
||
/** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */
|
||
rank?: number;
|
||
/** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */
|
||
startTime?: string;
|
||
audience?: string;
|
||
/** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */
|
||
why?: string;
|
||
stops?: PlannerStop[];
|
||
verified?: DataVerified;
|
||
source?: DataSource;
|
||
}
|
||
|
||
/**
|
||
* 섹션 타입 → 없으면 그 줄을 통째로 버리는 키.
|
||
*
|
||
* ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다.
|
||
* 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다.
|
||
*/
|
||
export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
|
||
songs: 'title',
|
||
daily: 'title',
|
||
course: 'name',
|
||
schedule: 'name',
|
||
people: 'name',
|
||
chronicle: 'title',
|
||
reading: 'title',
|
||
literature: 'workTitle',
|
||
postcard: 'line',
|
||
quiz: 'question',
|
||
planner: 'name',
|
||
itinerary: 'name',
|
||
video: 'url',
|
||
event: 'title',
|
||
};
|
||
|
||
/**
|
||
* 유튜브 주소 → 영상 id.
|
||
*
|
||
* ★ **유튜브가 아니면 undefined 다.** 아무 주소나 iframe 에 넣으면 남의 페이지를 우리 도메인
|
||
* 안에서 여는 통로가 된다. 알아보는 형식만 재생하고, 나머지는 링크로만 남긴다.
|
||
* ★ 네 가지 형식을 받는다 — 사장님이 복사해 오는 자리가 그때그때 다르다:
|
||
* `watch?v=ID` · `youtu.be/ID` · `shorts/ID`(모바일 공유) · `embed/ID`.
|
||
*/
|
||
export function youtubeId(url: string | undefined): string | undefined {
|
||
const text = (url ?? '').trim();
|
||
if (!text) return undefined;
|
||
const match =
|
||
/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec(
|
||
text,
|
||
);
|
||
return match?.[1];
|
||
}
|
||
|
||
/**
|
||
* 세로 영상인가.
|
||
*
|
||
* ★ 쇼츠는 9:16 이다. 16:9 틀에 넣으면 좌우가 까맣게 비고 영상이 손톱만 해진다 —
|
||
* 주소가 이미 말해 주는 것을 무시하지 않는다.
|
||
*/
|
||
export function isVerticalVideo(url: string | undefined): boolean {
|
||
return /youtube\.com\/shorts\//.test((url ?? '').trim());
|
||
}
|
||
|
||
/** 재생 전에 보여줄 표지. 유튜브가 영상마다 만들어 두는 것이라 우리가 만들지 않는다. */
|
||
export function youtubePoster(id: string): string {
|
||
return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
|
||
}
|
||
|
||
/**
|
||
* 재생용 주소.
|
||
*
|
||
* ★ `youtube-nocookie.com` 을 쓴다. 손님이 재생을 누르기 전에는 아무것도 안 붙고,
|
||
* 눌러도 광고 추적 쿠키를 먼저 심지 않는다.
|
||
*/
|
||
export function youtubeEmbed(id: string): string {
|
||
return `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&modestbranding=1`;
|
||
}
|
||
|
||
export interface ParsedSectionData<T> {
|
||
items: T[];
|
||
title?: string;
|
||
subtitle?: string;
|
||
/** 섹션 머리에 거는 바깥 링크. 그 섹션을 만든 서비스로 보내는 자리다(예: 영상 → ADO2). */
|
||
linkUrl?: string;
|
||
linkLabel?: string;
|
||
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
|
||
error?: string;
|
||
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
|
||
kindMismatch?: string;
|
||
/** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
|
||
unverified: number;
|
||
/** source 가 붙은 항목 수. */
|
||
sourced: number;
|
||
}
|
||
|
||
const EMPTY: ParsedSectionData<never> = {items: [], unverified: 0, sourced: 0};
|
||
|
||
/**
|
||
* JSON.parse 실패를 "몇 번째 줄"로 바꾼다.
|
||
*
|
||
* ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과,
|
||
* 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다.
|
||
* 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다.
|
||
*/
|
||
function locate(raw: string, message: string): string {
|
||
const where = (pos: number) => {
|
||
const before = raw.slice(0, Math.max(0, pos));
|
||
const line = before.split('\n').length;
|
||
const col = pos - before.lastIndexOf('\n');
|
||
return `${line}번째 줄 ${col}번째 글자`;
|
||
};
|
||
|
||
const token = /Unexpected token '(.)'/.exec(message)?.[1];
|
||
// 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다.
|
||
const hint =
|
||
token === '}' || token === ']'
|
||
? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.'
|
||
: '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.';
|
||
|
||
const lineCol = /line (\d+) column (\d+)/.exec(message);
|
||
if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`;
|
||
|
||
const at = /position (\d+)/.exec(message);
|
||
if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`;
|
||
|
||
// 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다.
|
||
const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1];
|
||
const found = quoted ? raw.indexOf(quoted) : -1;
|
||
if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`;
|
||
|
||
return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.';
|
||
}
|
||
|
||
/**
|
||
* 붙여넣은 문자열 → 렌더 가능한 항목.
|
||
*
|
||
* ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
|
||
* 발행 사이트에서도 같다 — 프리렌더가 예외로 죽으면 사이트 전체가 안 구워진다.
|
||
*/
|
||
export function parseSectionData<T extends object>(
|
||
sectionType: string,
|
||
raw: string | undefined,
|
||
): ParsedSectionData<T> {
|
||
const requiredKey = SECTION_ITEM_REQUIRED_KEY[sectionType];
|
||
const text = (raw ?? '').trim();
|
||
if (!requiredKey || !text) return EMPTY as ParsedSectionData<T>;
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch (error) {
|
||
return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')};
|
||
}
|
||
|
||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||
return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'};
|
||
}
|
||
|
||
const envelope = parsed as Record<string, unknown>;
|
||
const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined;
|
||
const rawItems = envelope.items;
|
||
if (!Array.isArray(rawItems)) {
|
||
return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'};
|
||
}
|
||
|
||
const items = rawItems.filter(
|
||
(item): item is T =>
|
||
typeof item === 'object' &&
|
||
item !== null &&
|
||
!Array.isArray(item) &&
|
||
typeof (item as Record<string, unknown>)[requiredKey] === 'string' &&
|
||
((item as Record<string, unknown>)[requiredKey] as string).trim().length > 0,
|
||
);
|
||
|
||
let unverified = 0;
|
||
let sourced = 0;
|
||
for (const item of items) {
|
||
const row = item as Record<string, unknown>;
|
||
if (row.verified !== '확인') unverified += 1;
|
||
if (row.source && typeof row.source === 'object') sourced += 1;
|
||
}
|
||
|
||
return {
|
||
items,
|
||
title: typeof envelope.title === 'string' ? envelope.title : undefined,
|
||
subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined,
|
||
// http(s) 만 받는다 — javascript: 를 그대로 <a href> 에 실으면 붙여넣은 JSON 이 스크립트가 된다.
|
||
linkUrl: typeof envelope.linkUrl === 'string' && /^https?:\/\//.test(envelope.linkUrl)
|
||
? envelope.linkUrl
|
||
: undefined,
|
||
linkLabel: typeof envelope.linkLabel === 'string' ? envelope.linkLabel : undefined,
|
||
kindMismatch: kind && kind !== sectionType ? kind : undefined,
|
||
unverified,
|
||
sourced,
|
||
};
|
||
}
|
||
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
* 계절별 추천 하루 — 시각을 **계산해서** 짜 준다.
|
||
*
|
||
* ★ 왜 shared 인가: 파서와 같은 이유다. 빌더 캔버스와 발행 사이트가 같은 코스에서 **같은 시각**을
|
||
* 내놓아야 한다. 조립 규칙이 두 벌이면 사장님이 본 일정과 손님이 보는 일정이 조용히 갈린다.
|
||
* ★ 여행 스케줄(schedule)과 축이 다르다 — 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다.
|
||
* 그래서 사장님은 "몇 분 걸리나"만 알면 되고, 출발 시각을 바꾸면 하루가 통째로 밀린다.
|
||
* ───────────────────────────────────────────────────────────── */
|
||
|
||
/** 밤 9시를 넘기는 칸은 넣지 않는다 — 짜 준 일정이 손님을 밤까지 끌고 다니면 그 순간 신뢰를 잃는다. */
|
||
const PLAN_ENDS_BY = 21 * 60;
|
||
/** 머무는 시간을 안 적었을 때. 0 으로 두면 한 시각에 칸이 겹쳐 쌓인다. */
|
||
const PLAN_DEFAULT_STAY = 60;
|
||
/** 출발 시각을 안 적었을 때. 체크아웃 뒤 움직이는 시각을 기본으로 잡는다. */
|
||
const PLAN_DEFAULT_START = '10:00';
|
||
|
||
/** 계절 탭 순서. 데이터에 있는 계절만 이 순서로 세운다 — 붙여넣은 순서대로 두면 겨울이 맨 앞에 온다. */
|
||
const SEASON_ORDER = ['봄', '여름', '가을', '겨울'];
|
||
|
||
/** "HH:MM" → 자정부터의 분. 형식이 아니면 undefined — 지어내지 않는다. */
|
||
export function minutesOfTime(time: string): number | undefined {
|
||
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
||
if (!match) return undefined;
|
||
const hour = Number(match[1]);
|
||
const minute = Number(match[2]);
|
||
if (hour > 23 || minute > 59) return undefined;
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
export function timeOfMinutes(total: number): string {
|
||
const wrapped = ((total % 1440) + 1440) % 1440;
|
||
return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
export interface PlannedStop {
|
||
stop: PlannerStop;
|
||
/** 도착 시각 "HH:MM". */
|
||
time: string;
|
||
/** 떠나는 시각 "HH:MM". */
|
||
until: string;
|
||
/** 앞 칸에서 오는 데 걸린 분. 0 이면 화면이 이동 줄을 그리지 않는다. */
|
||
move: number;
|
||
}
|
||
|
||
export interface PlannedDay {
|
||
stops: PlannedStop[];
|
||
/** 하루가 시작·끝나는 시각. 카드 머리에 "09:30–16:40" 으로 뜬다. */
|
||
from: string;
|
||
to: string;
|
||
/** 총 소요(분) — 이동 시간까지 포함한다. */
|
||
totalMinutes: number;
|
||
/** 21시 상한에 걸려 못 넣은 칸 수. 숨기지 않고 화면에 밝힌다. */
|
||
dropped: number;
|
||
}
|
||
|
||
/**
|
||
* 코스 하나 → 시각이 박힌 하루.
|
||
*
|
||
* 정거장 순서는 사장님이 적은 그대로다(적은 순서가 곧 도는 순서다). 출발 시각부터
|
||
* 이동·머무는 시간을 누적해 칸마다 도착·출발 시각을 박고, 상한을 넘기는 칸은 버린다 —
|
||
* 넘겨서라도 다 넣으면 자정에 끝나는 일정이 나온다.
|
||
*/
|
||
export function planDay(item: PlannerItem): PlannedDay {
|
||
const start = minutesOfTime(item.startTime ?? '') ?? minutesOfTime(PLAN_DEFAULT_START) ?? 600;
|
||
const stops: PlannedStop[] = [];
|
||
let clock = start;
|
||
let dropped = 0;
|
||
|
||
for (const stop of item.stops ?? []) {
|
||
const move = Math.max(0, stop.moveMinutes ?? 0);
|
||
const stay = Math.max(1, stop.minutes ?? PLAN_DEFAULT_STAY);
|
||
const arrive = clock + move;
|
||
if (arrive + stay > PLAN_ENDS_BY) {
|
||
dropped += 1;
|
||
continue;
|
||
}
|
||
stops.push({stop, time: timeOfMinutes(arrive), until: timeOfMinutes(arrive + stay), move});
|
||
clock = arrive + stay;
|
||
}
|
||
|
||
return {
|
||
stops,
|
||
from: timeOfMinutes(start),
|
||
to: timeOfMinutes(clock),
|
||
totalMinutes: clock - start,
|
||
dropped,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 지금 계절. **간절기에는 두 개**를 돌려준다.
|
||
*
|
||
* ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만
|
||
* 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다.
|
||
* ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 —
|
||
* 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다.
|
||
* ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데,
|
||
* 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다).
|
||
*/
|
||
export function currentSeasons(now: Date = new Date()): string[] {
|
||
const month = now.getMonth() + 1;
|
||
// 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다.
|
||
const index = Math.floor(((month - 3 + 12) % 12) / 3);
|
||
const season = SEASON_ORDER[index];
|
||
const isFirstMonth = month % 3 === 0;
|
||
if (isFirstMonth && now.getDate() <= 15) {
|
||
return [SEASON_ORDER[(index + 3) % 4], season];
|
||
}
|
||
return [season];
|
||
}
|
||
|
||
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
|
||
/**
|
||
* 이 항목이 지금 나갈 계절인가.
|
||
*
|
||
* ★ 계절을 가진 아이템은 셋이다 — 계절별 추천 하루(planner) · 여행 스케줄(schedule) ·
|
||
* 일력(daily). 한동안 planner 에만 걸려 있었다. "계절별 콘텐츠는 계절 따라 나간다"는
|
||
* 규칙은 아이템 하나가 아니라 계절을 적은 모든 아이템의 규칙이다.
|
||
* ★ 계절을 안 적은 항목은 **계절을 타지 않는 것**으로 본다 — 가려서는 안 된다.
|
||
* ★ schedule 의 season 은 자유 문구다("장마", "여름 장마"). 네 계절 이름이 섞여 있으면
|
||
* 그 계절로 보고, 우리가 못 읽는 값("장마")은 가리지 않는다 — 분류 못 하는 것을
|
||
* 숨기면 사장님이 쓴 콘텐츠가 아무 계절에도 안 나가는 일이 생긴다.
|
||
*/
|
||
export function inSeason(season: string | undefined, live: string[]): boolean {
|
||
const text = season?.trim();
|
||
if (!text) return true;
|
||
const named = SEASON_ORDER.filter((name) => text.includes(name));
|
||
if (named.length === 0) return true;
|
||
return named.some((name) => live.includes(name));
|
||
}
|
||
|
||
/**
|
||
* 일정 한 건 → 일자별로 시각이 박힌 결과.
|
||
*
|
||
* ★ `days` 가 없으면 하루짜리로 본다 — 반나절 코스가 그렇다.
|
||
* ★ 시각 계산은 `planDay` 한 벌이다. 하루짜리와 여러 날짜리가 다른 식을 쓰면
|
||
* 같은 코스가 자리에 따라 다른 시각을 낸다.
|
||
*/
|
||
export function itineraryDays(item: ItineraryItem): {label: string; day: PlannedDay}[] {
|
||
const raw =
|
||
item.days && item.days.length > 0
|
||
? item.days
|
||
: [{label: undefined, startTime: item.startTime, stops: item.stops}];
|
||
return raw
|
||
.map((entry, index) => ({
|
||
label: entry.label?.trim() || (raw.length > 1 ? `${index + 1}일차` : ''),
|
||
day: planDay({name: item.name, startTime: entry.startTime, stops: entry.stops}),
|
||
}))
|
||
.filter((entry) => entry.day.stops.length > 0);
|
||
}
|
||
|
||
/**
|
||
* 기간별 묶음. 목록의 축이다.
|
||
*
|
||
* ★ 적힌 순서를 지킨다 — 사장님이 쓴 순서가 곧 권하는 순서다.
|
||
* 기간을 안 적은 일정은 맨 뒤 한 덩이로 모은다(빈 소제목을 만들지 않는다).
|
||
*/
|
||
export function itineraryDurations(items: ItineraryItem[]): string[] {
|
||
const seen: string[] = [];
|
||
for (const item of items) {
|
||
const key = item.duration?.trim();
|
||
if (key && !seen.includes(key)) seen.push(key);
|
||
}
|
||
return seen;
|
||
}
|
||
|
||
export function plannerSeasons(items: PlannerItem[]): string[] {
|
||
const seen: string[] = [];
|
||
for (const item of items) {
|
||
const season = item.season?.trim();
|
||
if (season && !seen.includes(season)) seen.push(season);
|
||
}
|
||
return seen.sort((a, b) => {
|
||
const ai = SEASON_ORDER.indexOf(a);
|
||
const bi = SEASON_ORDER.indexOf(b);
|
||
return (ai < 0 ? SEASON_ORDER.length : ai) - (bi < 0 ? SEASON_ORDER.length : bi);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 그 계절의 추천 코스 — 순위대로 최대 세 개.
|
||
*
|
||
* ★ 셋에서 끊는다. 넷째부터는 아무도 안 고르고, 화면에서는 "추천"이 아니라 목록이 된다.
|
||
* ★ rank 를 안 적었으면 붙여넣은 순서가 순위다 — 순위 없는 코스를 1위로 올리지 않는다.
|
||
*/
|
||
export function plannerTop(items: PlannerItem[], season?: string): PlannerItem[] {
|
||
const picked = season ? items.filter((item) => item.season?.trim() === season) : [...items];
|
||
picked.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
|
||
return picked.slice(0, 3);
|
||
}
|