o2o-site-AEO/solution/shared/src/lib/section-data.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

680 lines
28 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 은 어떤 모양인가".
*
* ★ 왜 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);
}