generated from ttipo/project-base
fix(backend): 계절별 영업시간을 날짜의 달로 골라 영업 중 표시를 바로잡음 #10
@@ -219,7 +219,7 @@ export class PlaceService {
|
||||
private toListItem(
|
||||
v: PlaceListing,
|
||||
distance: number | null,
|
||||
clock: { weekday: number; minutes: number },
|
||||
clock: { date: string; minutes: number },
|
||||
): PlaceListItemDto {
|
||||
return {
|
||||
id: v.id,
|
||||
@@ -237,7 +237,7 @@ export class PlaceService {
|
||||
imageThumbUrl: v.imageThumbUrl,
|
||||
imageLicense: v.imageLicense,
|
||||
hoursOrigin: v.hoursOrigin,
|
||||
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
|
||||
openNow: v.hours ? isOpenAt(v.hours, clock.date, clock.minutes) : null,
|
||||
petPolicy: v.petPolicy,
|
||||
petOrigin: v.petOrigin,
|
||||
setting: v.setting,
|
||||
|
||||
@@ -43,10 +43,10 @@ describe('parseKstTimestamp', () => {
|
||||
});
|
||||
|
||||
describe('kstClock', () => {
|
||||
it('한국 시간의 요일과 자정 이후 분을 돌려준다', () => {
|
||||
it('한국 시간의 날짜와 자정 이후 분을 돌려준다', () => {
|
||||
// 2026-11-01(일) 15:30 UTC = 2026-11-02(월) 00:30 KST
|
||||
expect(kstClock(new Date('2026-11-01T15:30:00Z'))).toEqual({
|
||||
weekday: 1,
|
||||
date: '2026-11-02',
|
||||
minutes: 30,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,11 +35,11 @@ export function parseKstTimestamp(value: unknown): Date | null {
|
||||
return back.getUTCDate() === d ? date : null;
|
||||
}
|
||||
|
||||
/** 한국 시간 기준 요일(0=일요일 … 6=토요일)과 자정 이후 분 — "지금 영업 중" 판단에 쓴다 */
|
||||
export function kstClock(now: Date): { weekday: number; minutes: number } {
|
||||
/** 한국 시간 기준 날짜('YYYY-MM-DD')와 자정 이후 분 — "지금 영업 중" 판단에 쓴다(계절별 시간표는 날짜의 달로 고른다) */
|
||||
export function kstClock(now: Date): { date: string; minutes: number } {
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return {
|
||||
weekday: kst.getUTCDay(),
|
||||
date: kst.toISOString().slice(0, 10),
|
||||
minutes: kst.getUTCHours() * 60 + kst.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isOpenAt, parseOpeningHours } from './opening-hours';
|
||||
import {
|
||||
isOpenAt,
|
||||
parseOpeningHours,
|
||||
type ParsedOpeningHours,
|
||||
} from './opening-hours';
|
||||
|
||||
// 요일 번호: 0=일요일 … 6=토요일
|
||||
const SUN = 0;
|
||||
@@ -9,6 +13,12 @@ const THU = 4;
|
||||
const FRI = 5;
|
||||
const SAT = 6;
|
||||
const at = (h: number, m = 0) => h * 60 + m;
|
||||
/** 2026년 그 달(기본 11월)의 첫 번째 그 요일 — 'YYYY-MM-DD' */
|
||||
const on = (weekday: number, month = 11): string => {
|
||||
const firstWeekday = new Date(Date.UTC(2026, month - 1, 1)).getUTCDay();
|
||||
const day = 1 + ((weekday - firstWeekday + 7) % 7);
|
||||
return `2026-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다
|
||||
describe('parseOpeningHours', () => {
|
||||
@@ -27,8 +37,8 @@ describe('parseOpeningHours', () => {
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => {
|
||||
@@ -39,7 +49,7 @@ describe('parseOpeningHours', () => {
|
||||
expect(a.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
|
||||
const b = parseOpeningHours('10:00~21:30 (마지막 주문 21:00)', '연중무휴');
|
||||
expect(b.lastOrder).toBe(at(21));
|
||||
expect(isOpenAt(b, FRI, at(21, 15))).toBe(true);
|
||||
expect(isOpenAt(b, on(FRI), at(21, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => {
|
||||
@@ -49,8 +59,8 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.breaksWeekdaysOnly).toBe(true);
|
||||
expect(p.lastOrder).toBe(at(20, 30));
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(16))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(16))).toBe(true);
|
||||
expect(p.holidayClosures).toEqual(
|
||||
expect.arrayContaining(['설', '추석', '1월 1일']),
|
||||
);
|
||||
@@ -63,57 +73,65 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(p.week[SUN]).toEqual([]);
|
||||
expect(isOpenAt(p, SUN, at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(false);
|
||||
expect(p.notes).toContain('재료 소진 시 조기 마감');
|
||||
});
|
||||
|
||||
it('평일·주말 시간이 다르면 요일별로 나눈다', () => {
|
||||
const p = parseOpeningHours('평일 11:00~22:00 / 주말 10:00~22:00', '');
|
||||
expect(isOpenAt(p, SAT, at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(10, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(10, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('요일 범위(화~일)와 월요일 휴관을 읽는다', () => {
|
||||
const p = parseOpeningHours('화~일 10:00~18:00 (월요일 휴관)', '');
|
||||
expect(p.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(p, SUN, at(17))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(17))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('자정을 넘기는 영업은 다음 날 새벽까지 이어진다', () => {
|
||||
const p = parseOpeningHours('18:00~02:00', '매주 월요일');
|
||||
expect(p.week[SUN]).toEqual([{ start: at(18), end: at(26) }]);
|
||||
// 일요일 밤에 열어 월요일 새벽 1시까지 — 월요일이 휴무여도 전날 영업은 이어진다
|
||||
expect(isOpenAt(p, MON, at(1))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(19))).toBe(false);
|
||||
expect(isOpenAt(p, on(MON), at(1))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(19))).toBe(false);
|
||||
});
|
||||
|
||||
it('상시 개방과 24시간은 항상 연 것으로 본다', () => {
|
||||
expect(parseOpeningHours('상시 개방').status).toBe('always_open');
|
||||
const p = parseOpeningHours('24시간', '연중무휴');
|
||||
expect(p.status).toBe('always_open');
|
||||
expect(isOpenAt(p, WED, at(3))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(3))).toBe(true);
|
||||
});
|
||||
|
||||
it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => {
|
||||
expect(parseOpeningHours('점포별 상이').status).toBe('varies');
|
||||
const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고');
|
||||
expect(p.status).toBe('varies');
|
||||
expect(isOpenAt(p, SAT, at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('값이 없으면 미확인으로 둔다', () => {
|
||||
expect(parseOpeningHours(null).status).toBe('unknown');
|
||||
expect(parseOpeningHours('').status).toBe('unknown');
|
||||
expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull();
|
||||
expect(isOpenAt(parseOpeningHours(undefined), on(SAT), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => {
|
||||
it('달을 적지 않은 하절기·동절기는 확실한 달에 그 계절 시간을 쓰고, 3·10·11월에는 두 시간이 같을 때만 답한다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(12))).toBeNull();
|
||||
expect(p.seasons).toHaveLength(2);
|
||||
expect(isOpenAt(p, on(WED, 7), at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(10, 30))).toBe(false);
|
||||
// 11월은 어느 계절인지 기관마다 다르다 — 두 계절 모두 여는 시각만 연다고 답한다
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(10, 30))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED), at(18))).toBe(false);
|
||||
// 월요일은 어느 계절에도 시간이 없다
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('줄바꿈 태그와 안내 문구를 정리한다', () => {
|
||||
@@ -142,14 +160,14 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
|
||||
expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
|
||||
expect(isOpenAt(p, MON, at(20, 15))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(20, 15))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(20, 15))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(20, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => {
|
||||
const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00');
|
||||
expect(isOpenAt(p, MON, at(17, 30))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(MON), at(17, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(17, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('줄바꿈은 구간 구분으로 본다', () => {
|
||||
@@ -163,8 +181,8 @@ describe('parseOpeningHours', () => {
|
||||
it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => {
|
||||
const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)');
|
||||
expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => {
|
||||
@@ -191,15 +209,18 @@ describe('parseOpeningHours', () => {
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.breaksWeekdaysOnly).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(15))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(15))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(15))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(15))).toBe(true);
|
||||
expect(p.lastOrder).toBe(at(21, 40));
|
||||
});
|
||||
|
||||
it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => {
|
||||
it('달 머리말("[1월]")은 월요일로 읽지 않고 그 달의 시간표로 둔다 — 적히지 않은 달은 판단하지 않는다', () => {
|
||||
const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00');
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(18, 45))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 2), at(18, 45))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 3), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => {
|
||||
@@ -222,8 +243,8 @@ describe('parseOpeningHours', () => {
|
||||
expect(p.week[WED]).toEqual([{ start: at(11), end: at(21) }]);
|
||||
expect(p.breaks).toEqual([{ start: at(16), end: at(17) }]);
|
||||
expect(p.lastOrder).toBe(at(20, 20));
|
||||
expect(isOpenAt(p, WED, at(12, 30))).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(16, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('붙은 글머리표 뒤의 마지막 주문을 여는 시각으로 읽지 않는다(서민갈비)', () => {
|
||||
@@ -259,17 +280,17 @@ describe('parseOpeningHours', () => {
|
||||
{ start: at(5), end: at(20) },
|
||||
{ start: at(19), end: at(23, 30) },
|
||||
]);
|
||||
expect(isOpenAt(p, SUN, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(22))).toBe(true);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(22))).toBe(true);
|
||||
});
|
||||
|
||||
it('시설마다 평일·주말 시간이 다르면 어느 시설이든 여는 시간을 영업으로 본다(스파밸리)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[실내]- 09:00~18:00[야외]- 평일 09:00~17:00- 주말 09:30~17:30',
|
||||
);
|
||||
expect(isOpenAt(p, WED, at(17, 45))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(9, 15))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(17, 45))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(9, 15))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(18, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('요일 사이의 / 는 구간 구분이 아니라 나열이다(영생곱창)', () => {
|
||||
@@ -289,14 +310,14 @@ describe('parseOpeningHours', () => {
|
||||
expect(p.week[WED]).toEqual([{ start: at(9), end: at(20) }]);
|
||||
expect(p.week[SAT]).toEqual([{ start: at(9), end: at(18) }]);
|
||||
expect(p.week[MON]).toBeNull();
|
||||
expect(isOpenAt(p, WED, at(12, 30))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
it('2시간 이하의 점심시간은 쉬는 시간, 그보다 길면 점심 영업으로 읽는다', () => {
|
||||
const office = parseOpeningHours('09:00~18:00 (점심시간 13:00~14:00)');
|
||||
expect(office.breaks).toEqual([{ start: at(13), end: at(14) }]);
|
||||
expect(isOpenAt(office, WED, at(13, 30))).toBe(false);
|
||||
expect(isOpenAt(office, on(WED), at(13, 30))).toBe(false);
|
||||
const restaurant = parseOpeningHours(
|
||||
'점심시간 11:30~14:30 / 저녁시간 17:00~21:00',
|
||||
);
|
||||
@@ -313,14 +334,14 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.breaks).toEqual([]);
|
||||
expect(p.week[WED]).toEqual([{ start: at(10), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
// 2026-11-05 관광공사 대구 상세 점검 — 쉬는날을 잘못 읽어 영업 중 표시가 틀린 17곳
|
||||
it('쉬는날의 요일 범위는 가운데 요일까지 휴무로 본다(북성로공구빵·엘리몽·대구 북구문화원)', () => {
|
||||
const range = parseOpeningHours('10:00~20:00', '매주 월요일~목요일');
|
||||
expect(range.closedWeekdays).toEqual([MON, TUE, WED, THU]);
|
||||
expect(isOpenAt(range, TUE, at(12))).toBe(false);
|
||||
expect(isOpenAt(range, on(TUE), at(12))).toBe(false);
|
||||
const weekdays = parseOpeningHours('10:00~18:00', '매주 평일(월~금)');
|
||||
expect(weekdays.closedWeekdays).toEqual([MON, TUE, WED, THU, FRI]);
|
||||
const overSunday = parseOpeningHours(
|
||||
@@ -336,8 +357,8 @@ describe('parseOpeningHours', () => {
|
||||
'주말 / 공휴일',
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN, SAT]);
|
||||
expect(isOpenAt(p, SAT, at(10))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(10))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(10))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(10))).toBe(true);
|
||||
expect(parseOpeningHours('10:30~20:30', '주말').closedWeekdays).toEqual([
|
||||
SUN,
|
||||
SAT,
|
||||
@@ -350,8 +371,8 @@ describe('parseOpeningHours', () => {
|
||||
'매주 월요일 (단, 월요일이 공휴일 때에는 공휴일 다음의 첫번째 평일) / 1월 1일 / 설날·추석 당일',
|
||||
);
|
||||
expect(museum.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(museum, MON, at(12))).toBe(false);
|
||||
expect(isOpenAt(museum, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(museum, on(MON), at(12))).toBe(false);
|
||||
expect(isOpenAt(museum, on(TUE), at(12))).toBe(true);
|
||||
const village = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설날·추석 연휴일 다음의 첫 번째 평일',
|
||||
@@ -404,7 +425,7 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(p.notes[0]).toContain('마지막주 월요일');
|
||||
expect(isOpenAt(p, MON, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(true);
|
||||
// '정기휴무' 같은 말머리는 시설 이름이 아니다
|
||||
const labeled = parseOpeningHours(
|
||||
'10:00~20:00',
|
||||
@@ -420,7 +441,7 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
expect(p.notes[0]).toContain('첫째주');
|
||||
expect(isOpenAt(p, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(TUE), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호 안의 주기는 앞 구절을 꾸민다 — "매주 월요일 (매월 2주, 4주)" 는 매주 휴무가 아니다(대구광역시립남부도서관)', () => {
|
||||
@@ -449,28 +470,228 @@ describe('parseOpeningHours', () => {
|
||||
'[북카페]- 일요일[북키즈 / 다목적홀]- 주말',
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(isOpenAt(p, SAT, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('시기마다 쉬는날이 다르면 어느 시기인지 몰라 시각 판단을 하지 않는다(대구창의융합교육원)', () => {
|
||||
it('시기마다 쉬는날이 다르면 날짜의 달로 그 시기의 휴무 요일을 고른다(대구창의융합교육원)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'[3월~7월 / 9월~12월] - 월요일 / 공휴일[1월 / 2월 / 8월] - 일요일 / 공휴일',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(p.closedWeekdays).toEqual([SUN, MON]);
|
||||
expect(isOpenAt(p, MON, at(12))).toBeNull();
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
expect(p.restSeasons.map((r) => r.closedWeekdays)).toEqual([[MON], [SUN]]);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON, 8), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SUN, 8), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('영업시간 문장 속 휴무 구절은 같은 구절의 여는 요일 범위를 휴무로 읽지 않는다', () => {
|
||||
const p = parseOpeningHours('월~금 10:00~18:00 토요일 휴무', '');
|
||||
expect(p.closedWeekdays).toEqual([SAT]);
|
||||
expect(isOpenAt(p, WED, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
// 괄호 안 조건 속 요일도 휴무로 읽지 않는다
|
||||
const q = parseOpeningHours(
|
||||
'화~일 10:00~18:00 (월요일 휴관, 단 월요일이 공휴일이면 화요일 휴관)',
|
||||
);
|
||||
expect(q.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(q, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(q, on(TUE), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
// 2026-11-06 관광공사 대구 원문 — 계절별 시간표 32곳(그때까지 영업시간 미확인 23곳, 1년 내내 같은 시간으로 읽힌 9곳)
|
||||
describe('계절별 시간표', () => {
|
||||
it('달을 적은 계절 머리말은 그 달에 그 계절 시간을 쓴다(대구미술관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~10월)]- 10:00~19:00- 입장 마감 18:00[동절기(11월~3월)]- 10:00~18:00- 입장 마감 17:00',
|
||||
'매주 월요일 (단, 월요일이 공휴일인 경우 그 다음 평일 휴관) / 1월 1일 / 설·추석',
|
||||
);
|
||||
expect(p.seasons.map((s) => s.months)).toEqual([
|
||||
[4, 5, 6, 7, 8, 9, 10],
|
||||
[1, 2, 3, 11, 12],
|
||||
]);
|
||||
expect(isOpenAt(p, on(WED), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 6), at(18, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
expect(p.notes).toContain('입장 마감 17:00');
|
||||
});
|
||||
|
||||
it('시각 바로 뒤에 붙은 계절 머리말을 나눠 읽는다(수창청춘맨숀·대구문화예술회관)', () => {
|
||||
const undated = parseOpeningHours(
|
||||
'하절기 10:00~19:00동절기 10:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설날·추석 당일',
|
||||
);
|
||||
expect(isOpenAt(undated, on(WED), at(14))).toBe(true);
|
||||
expect(isOpenAt(undated, on(WED), at(18, 30))).toBeNull();
|
||||
expect(isOpenAt(undated, on(WED, 1), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(undated, on(WED, 7), at(18, 30))).toBe(true);
|
||||
const dated = parseOpeningHours(
|
||||
'하절기(4월1일~10월31일) 10:00~19:00동절기(11월1일~3월31일) 10:00~18:00',
|
||||
);
|
||||
expect(isOpenAt(dated, on(WED), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(dated, on(WED, 10), at(18, 30))).toBe(true);
|
||||
});
|
||||
|
||||
it('달 중간에 바뀌는 계절은 그 달을 확실하지 않은 달로 둔다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'하절기(3월 15일~10월) 09:00~18:00 / 동절기(11월~3월 14일) 09:00~17:00',
|
||||
);
|
||||
expect(p.seasons[0].sureMonths).not.toContain(3);
|
||||
expect(isOpenAt(p, on(WED, 3), at(17, 30))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED, 3), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('"4~10월"·글머리표 뒤의 달 머리말도 계절로 읽는다(향촌문화관·달성 한일우호관·화본마을)', () => {
|
||||
const short = parseOpeningHours(
|
||||
'- 하절기(4~10월) 09:00~19:00- 동절기(11~3월) 09:00~18:00※ 30분 전까지 입장 가능',
|
||||
);
|
||||
expect(isOpenAt(short, on(WED, 3), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(short, on(WED, 4), at(18, 30))).toBe(true);
|
||||
const months = parseOpeningHours(
|
||||
'- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
|
||||
);
|
||||
expect(isOpenAt(months, on(WED), at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(months, on(WED, 10), at(17, 30))).toBe(true);
|
||||
const museum = parseOpeningHours(
|
||||
'[박물관]- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
|
||||
);
|
||||
expect(isOpenAt(museum, on(WED), at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(museum, on(WED, 3), at(17, 30))).toBe(true);
|
||||
});
|
||||
|
||||
it('대괄호 안에 달을 나열한 머리말도 계절로 읽는다(대구수목원)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[1월~4월/9월~12월]09:00~18:00[5월~8월]08:00~19:00',
|
||||
'매주 월요일',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED), at(8, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 7), at(8, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('머리말 없는 기본 시간은 계절 시간이 없는 달과 요일에 쓴다(숨쉴꽃 허브 체험농장)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'- 10:00~17:00[하절기(6~8월)]- 10:00~13:00',
|
||||
'매주 토요일',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED, 7), at(15))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(15))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(false);
|
||||
const weekendOnly = parseOpeningHours(
|
||||
'09:00~18:00 / 하절기 주말 09:00~20:00',
|
||||
);
|
||||
expect(isOpenAt(weekendOnly, on(SAT, 7), at(19))).toBe(true);
|
||||
expect(isOpenAt(weekendOnly, on(WED, 7), at(17))).toBe(true);
|
||||
expect(isOpenAt(weekendOnly, on(WED, 7), at(19))).toBe(false);
|
||||
});
|
||||
|
||||
it('계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~11월)]\n\n- 평일 09:00~18:00\n\n- 주말 10:00~18:00\n\n[동절기(12월~3월)]\n\n- 평일 09:00~18:00\n\n- 주말 미개방\n\n※ 자세한 사항은 전화문의 요망',
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.status).toBe('parsed');
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호에 달만 적은 구간은 그 기간의 시간이고, 다른 시설 시간은 계절과 함께 연다(디아크문화관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[B1-2F 전시실]- 10:00~17:30[3F카페&전망대]- 10:00~23:00 (4월~11월)- 10:00~22:00 (12월~3월)[B1 자전거종주인증]- 10:00~22:00',
|
||||
'1월 1일 / 매주 월요일 (단, 공휴일 제외)',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED, 1), at(22, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 7), at(22, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(11))).toBe(true);
|
||||
expect(p.notes).not.toContain('4월~11월');
|
||||
});
|
||||
|
||||
it('쉬는날에 기간만 적으면 그 기간에는 문을 열지 않는다(상주식당·강변축구장·사문진피크닉장)', () => {
|
||||
const restaurant = parseOpeningHours('10:00~19:00', '매년 12월~3월');
|
||||
expect(restaurant.closedPeriods.map((c) => c.months)).toEqual([
|
||||
[1, 2, 3, 12],
|
||||
]);
|
||||
expect(isOpenAt(restaurant, on(WED, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(restaurant, on(WED), at(12))).toBe(true);
|
||||
const field = parseOpeningHours(
|
||||
'[하절기 (4~9월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 19:00 - 야간 19:00 ~ 22:00 [동절기 (10~3월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 18:00 - 야간 18:00 ~ 22:00',
|
||||
'동절기 (11월~2월)',
|
||||
);
|
||||
expect(isOpenAt(field, on(WED, 12), at(12))).toBe(false);
|
||||
expect(isOpenAt(field, on(WED, 10), at(21))).toBe(true);
|
||||
expect(isOpenAt(field, on(WED, 7), at(7, 30))).toBe(true);
|
||||
const picnic = parseOpeningHours(
|
||||
'10:00~18:00※ 3월~11월에 운영함으로 자세한 사항은 홈페이지 참조',
|
||||
'매주 월요일 (단, 월요일이 법정공휴일인 경우 다음날 휴무) / 12월~2월',
|
||||
);
|
||||
expect(picnic.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(picnic, on(WED, 2), at(12))).toBe(false);
|
||||
expect(isOpenAt(picnic, on(WED), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('"12월~2월 휴장"처럼 쉰다는 말이 붙은 기간도 문을 열지 않는 기간이다(영업시간·쉬는날 모두)', () => {
|
||||
const hours = parseOpeningHours('10:00~18:00 / 12월~2월 휴장');
|
||||
expect(isOpenAt(hours, on(WED, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(hours, on(WED), at(12))).toBe(true);
|
||||
const rest = parseOpeningHours(
|
||||
'10:00~18:00',
|
||||
'매주 월요일 / 1월~2월 휴장',
|
||||
);
|
||||
expect(rest.closedPeriods.map((c) => c.months)).toEqual([[1, 2]]);
|
||||
expect(rest.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(rest, on(WED, 2), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('날짜("1월 1일")와 예외 문구 속 달은 문을 닫는 기간이 아니다(달성 한일우호관·도학야영장)', () => {
|
||||
const museum = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설 / 추석 당일',
|
||||
);
|
||||
expect(museum.closedPeriods).toEqual([]);
|
||||
expect(museum.holidayClosures).toContain('1월 1일');
|
||||
const camp = parseOpeningHours(
|
||||
'14:00~익일 12:00',
|
||||
'매주 수요일 (※ 성수기 7월~8월, 10월~11월 15일은 제외)',
|
||||
);
|
||||
expect(camp.closedPeriods).toEqual([]);
|
||||
expect(camp.closedWeekdays).toEqual([WED]);
|
||||
expect(camp.seasonal).toBe(false);
|
||||
});
|
||||
|
||||
it('계절을 말하지만 기간을 읽지 못하면 시각 판단을 하지 않는다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'10:00~18:00 (여름철 19:00까지)',
|
||||
'매주 월요일',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(p.seasons).toEqual([]);
|
||||
expect(isOpenAt(p, on(WED, 7), at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('계절별 시간표를 두기 전에 저장한 해석 결과는 판단하지 않는다', () => {
|
||||
const stored = {
|
||||
...parseOpeningHours('09:00~18:00'),
|
||||
seasonal: true,
|
||||
seasons: undefined,
|
||||
} as unknown as ParsedOpeningHours;
|
||||
expect(isOpenAt(stored, on(WED), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('자정을 넘긴 계절 영업은 다음 날 새벽까지 이어진다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~10월)] 18:00~02:00 [동절기(11월~3월)] 18:00~24:00',
|
||||
);
|
||||
// 7월 첫 목요일 새벽 1시 — 수요일 밤 영업이 이어진다
|
||||
expect(isOpenAt(p, on(THU, 7), at(1))).toBe(true);
|
||||
expect(isOpenAt(p, on(THU, 1), at(1))).toBe(false);
|
||||
});
|
||||
|
||||
it('날짜 형식이 아니면 판단하지 않는다', () => {
|
||||
expect(
|
||||
isOpenAt(parseOpeningHours('09:00~18:00'), '11/06', at(12)),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00",
|
||||
* "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일".
|
||||
* AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다.
|
||||
* "하절기(4월~10월) … 동절기(11월~3월) …" 처럼 계절마다 다르면 계절별 시간표를 두고 날짜의 달로 고른다.
|
||||
* 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다.
|
||||
*/
|
||||
|
||||
@@ -26,22 +27,58 @@ export type OpeningHoursStatus =
|
||||
| 'varies'
|
||||
| 'unknown';
|
||||
|
||||
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
|
||||
export type WeekHours = (TimeRange[] | null)[];
|
||||
|
||||
/** 1년 중 일부 기간(달 단위) — 계절·시기 머리말 하나 */
|
||||
export interface MonthPeriod {
|
||||
/** 머리말 원문("하절기(4월~10월)"). 머리말 없는 기본 시간은 빈 문자열 */
|
||||
label: string;
|
||||
/** 이 기간일 수 있는 달(1~12) */
|
||||
months: number[];
|
||||
/**
|
||||
* 이 기간이 확실한 달. 달을 적지 않은 하절기·동절기는 어느 범위로 보든 그 계절인 달만 넣고,
|
||||
* "4월 15일~" 처럼 달 중간에 바뀌는 달은 뺀다
|
||||
*/
|
||||
sureMonths: number[];
|
||||
}
|
||||
|
||||
/** 계절·시기별 시간표 */
|
||||
export interface SeasonHours extends MonthPeriod {
|
||||
/** 그 기간의 요일별 영업 구간(정기 휴무 반영) */
|
||||
week: WeekHours;
|
||||
}
|
||||
|
||||
/** 시기별 정기 휴무 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
|
||||
export interface SeasonRest extends MonthPeriod {
|
||||
closedWeekdays: number[];
|
||||
}
|
||||
|
||||
export interface ParsedOpeningHours {
|
||||
status: OpeningHoursStatus;
|
||||
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
|
||||
week: (TimeRange[] | null)[];
|
||||
/** 1년 내내 같은 요일별 영업 구간. 계절별 시간표(seasons)가 있으면 정기 휴무만 남기고 비운다 */
|
||||
week: WeekHours;
|
||||
/** 준비시간(브레이크 타임) */
|
||||
breaks: TimeRange[];
|
||||
/** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */
|
||||
breaksWeekdaysOnly: boolean;
|
||||
/** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */
|
||||
lastOrder: number | null;
|
||||
/** 정기 휴무 요일(0=일요일) */
|
||||
/** 1년 내내 쉬는 정기 휴무 요일(0=일요일) */
|
||||
closedWeekdays: number[];
|
||||
/** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */
|
||||
holidayClosures: string[];
|
||||
/** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */
|
||||
/**
|
||||
* 계절·시기마다 다르다(seasons·restSeasons·closedPeriods 중 하나가 있다).
|
||||
* 계절을 말하는데 기간을 읽지 못했으면 셋 다 비고 week 도 비워 시각 판단을 하지 않는다
|
||||
*/
|
||||
seasonal: boolean;
|
||||
/** 계절·시기별 시간표 — 날짜의 달로 고른다 */
|
||||
seasons: SeasonHours[];
|
||||
/** 시기별 정기 휴무(closedWeekdays 에 더한다) */
|
||||
restSeasons: SeasonRest[];
|
||||
/** 문을 열지 않는 기간 — 쉬는날 "매년 12월~3월", "동절기 (11월~2월)" */
|
||||
closedPeriods: MonthPeriod[];
|
||||
/** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */
|
||||
notes: string[];
|
||||
}
|
||||
@@ -65,12 +102,19 @@ const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/;
|
||||
const SEGMENT_AFTER_TIME =
|
||||
/(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g;
|
||||
/**
|
||||
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이 오는 곳.
|
||||
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20".
|
||||
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
|
||||
* "11:00- 21:00" 처럼 뒤에 숫자가 오면 시간 범위라 나누지 않는다.
|
||||
* 시각 뒤에 이어지는 계절·달 머리말 — "10:00~19:00동절기 10:00~18:00"(수창청춘맨숀),
|
||||
* "09:00~18:00 11월~2월 09:00~17:00"
|
||||
*/
|
||||
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—])/g;
|
||||
const SEASON_AFTER_TIME =
|
||||
/(\d{1,2}:\d{2})\s*(?=하절기|동절기|\d{1,2}\s*월\s*~)/g;
|
||||
/**
|
||||
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이나 달 머리말이 오는 곳.
|
||||
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20",
|
||||
* 2026-11-06: "- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00"(달성 한일우호관).
|
||||
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
|
||||
* "11:00- 21:00" 처럼 뒤에 시각이 오면 시간 범위라 나누지 않는다.
|
||||
*/
|
||||
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—]|\d{1,2}\s*월)/g;
|
||||
/** 쉬는 시간(브레이크)을 뜻하는 말 */
|
||||
const BREAK_WORDS = /준비\s*시간|브레이크|휴식\s*시간|휴게\s*시간|break/i;
|
||||
/**
|
||||
@@ -85,6 +129,58 @@ const BEFORE_BRACKET = /(\S)\s*(?=\[)/g;
|
||||
const SLASH_BETWEEN_DAYS =
|
||||
/([월화수목금토일](?:요일)?)\s*\/\s*(?=[월화수목금토일](?:요일)?(?:[\s,·~]|$))/g;
|
||||
|
||||
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
/** 달마다 날 수 — 그 달 끝날까지 적었는지 본다(2월 28일까지면 2월 전체로 본다) */
|
||||
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
/** 계절을 뜻하는 말 */
|
||||
const SEASON_WORDS_SOURCE = '하절기|동절기|여름철?|겨울철?|성수기|비수기';
|
||||
const SEASON_WORD = new RegExp(SEASON_WORDS_SOURCE);
|
||||
const SEASON_WORDS = new RegExp(SEASON_WORDS_SOURCE, 'g');
|
||||
const SUMMER_WORD = /하절기|여름/;
|
||||
const WINTER_WORD = /동절기|겨울/;
|
||||
/** 달 범위 — "4월~10월", "4~10월", "4월1일~10월31일". 묶음: 시작 달·시작 날·끝 달·끝 날 */
|
||||
const MONTH_SPAN_SOURCE = String.raw`(\d{1,2})\s*월?\s*(?:(\d{1,2})\s*일)?\s*~\s*(\d{1,2})\s*월\s*(?:(\d{1,2})\s*일)?`;
|
||||
/** 달 하나 — "8월". "1월 1일" 같은 날짜는 기간이 아니다 */
|
||||
const SINGLE_MONTH_SOURCE = String.raw`(\d{1,2})\s*월(?!\s*\d{1,2}\s*일)`;
|
||||
const MONTH_SPAN = new RegExp(MONTH_SPAN_SOURCE, 'g');
|
||||
const SINGLE_MONTH = new RegExp(SINGLE_MONTH_SOURCE, 'g');
|
||||
const MONTH_ITEM_SOURCE = `(?:${MONTH_SPAN_SOURCE}|${SINGLE_MONTH_SOURCE})`;
|
||||
const MONTH_LIST_SOURCE = String.raw`${MONTH_ITEM_SOURCE}(?:\s*[,·;]\s*${MONTH_ITEM_SOURCE})*`;
|
||||
/** 줄 앞의 계절·달 머리말 — "하절기(4월~10월) …", "하절기 (4월~9월) …", "3월~10월 …", "하절기 화~일 …" */
|
||||
const LEADING_PERIOD = new RegExp(
|
||||
String.raw`^(?:(?:${SEASON_WORDS_SOURCE})(?:\s*\(\s*${MONTH_LIST_SOURCE}\s*\))?|${MONTH_LIST_SOURCE})(?=[\s::(\d-]|$)\s*[::-]?`,
|
||||
);
|
||||
/** 기간 표현 사이에 올 수 있는 말 — 나열·괄호·'매년' */
|
||||
const PERIOD_FILLER = /[\s,·;~()[\]]|및|매년/g;
|
||||
/** 계절을 말하는 문장 — 기간을 읽지 못하면 시각 판단을 하지 않는다 */
|
||||
const SEASON_HINT =
|
||||
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/;
|
||||
/**
|
||||
* 달을 적지 않은 계절의 기간. 2026-11-06 대구 관광공사 원문 15곳은 하절기를 3~10월·4~9월·4~10월·4~11월 등으로 달리 쓴다.
|
||||
* 그래서 해당할 수 있는 달은 넓게, 확실한 달은 어느 범위로 보든 그 계절인 달만 둔다(하절기 4~9월, 동절기 12~2월).
|
||||
* 3·10·11월에는 두 계절의 시간표가 같은 답을 낼 때만 답한다
|
||||
*/
|
||||
const UNDATED_SUMMER = {
|
||||
months: [3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
sureMonths: [4, 5, 6, 7, 8, 9],
|
||||
};
|
||||
const UNDATED_WINTER = {
|
||||
months: [1, 2, 3, 10, 11, 12],
|
||||
sureMonths: [1, 2, 12],
|
||||
};
|
||||
/** 달을 적지 않은 성수기·비수기는 언제인지 알 수 없다 */
|
||||
const UNDATED_ANY = { months: ALL_MONTHS, sureMonths: [] };
|
||||
/** 문을 열지 않는다는 말 — "12월~2월 휴장" */
|
||||
const CLOSED_WORDS_SOURCE = String.raw`휴장|휴무|휴관|휴업|미운영|운영\s*(?:안\s*함|하지\s*않음|중단)`;
|
||||
const CLOSED_WORDS = new RegExp(`^(?:${CLOSED_WORDS_SOURCE})$`);
|
||||
/** 구절 끝의 문을 열지 않는다는 말 — 쉬는날 "1월~2월 휴장" */
|
||||
const CLOSED_SUFFIX = new RegExp(String.raw`\s*(?:${CLOSED_WORDS_SOURCE})$`);
|
||||
/** 요일과 쉰다는 말만 있는 구절 — "주말 미개방"(영남제일관 동절기), "평일 휴관" */
|
||||
const CLOSED_DAYS = /^(.+?)\s*(?:미개방|휴무|휴관|휴장|미운영)$/;
|
||||
/** 요일을 나타내는 말(쉬는 요일 구절이 요일만으로 이뤄졌는지 볼 때 지운다) */
|
||||
const DAY_WORDS = /평일|주말|주중|[일월화수목금토](?:요일)?|및|[\s~,·]/g;
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */
|
||||
function protectInnerSlashes(text: string): string {
|
||||
return text
|
||||
@@ -105,6 +201,7 @@ function normalize(text: string): string {
|
||||
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
|
||||
.replace(BULLET_AFTER_TIME, '$1 / ')
|
||||
.replace(SEGMENT_AFTER_TIME, '$1 / ')
|
||||
.replace(SEASON_AFTER_TIME, '$1 / ')
|
||||
.replace(BEFORE_BRACKET, '$1 / ')
|
||||
.replace(/(^|\/)\s*-\s*/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -116,6 +213,14 @@ function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
|
||||
return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0);
|
||||
}
|
||||
|
||||
/** 시간 범위가 하나라도 있는가 */
|
||||
function hasTimeRange(text: string): boolean {
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
const found = TIME_RANGE.test(text);
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseRanges(segment: string): TimeRange[] {
|
||||
const ranges: TimeRange[] = [];
|
||||
for (const m of segment.matchAll(TIME_RANGE)) {
|
||||
@@ -168,6 +273,67 @@ function parseDays(prefix: string): number[] | null {
|
||||
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
||||
}
|
||||
|
||||
/** 요일과 쉰다는 말만 있는 구절이면 쉬는 요일 — "주말 미개방" → [0, 6]. 아니면 null */
|
||||
function daysClosedBy(part: string): number[] | null {
|
||||
const m = CLOSED_DAYS.exec(part.trim());
|
||||
if (!m || m[1].replace(DAY_WORDS, '') !== '') return null;
|
||||
return parseDays(m[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 머리말에서 기간(달)을 읽는다 — "하절기(4월~10월)", "4~10월", "[1월~4월 ; 9월~12월]", "하절기".
|
||||
* 달을 적었으면 그 달, 계절 이름만 있으면 흔히 쓰는 범위로 둔다. 달도 계절도 없으면 null
|
||||
*/
|
||||
function parsePeriod(label: string): MonthPeriod | null {
|
||||
const months = new Set<number>();
|
||||
const partial = new Set<number>();
|
||||
const isMonth = (m: number) => m >= 1 && m <= 12;
|
||||
for (const m of label.matchAll(MONTH_SPAN)) {
|
||||
const from = Number(m[1]);
|
||||
const to = Number(m[3]);
|
||||
if (!isMonth(from) || !isMonth(to)) continue;
|
||||
for (let month = from; ; month = (month % 12) + 1) {
|
||||
months.add(month);
|
||||
if (month === to) break;
|
||||
}
|
||||
// 달 중간에 시작하거나 끝나면 그 달은 어느 쪽인지 날짜를 봐야 한다
|
||||
if (m[2] && Number(m[2]) > 1) partial.add(from);
|
||||
if (m[4] && Number(m[4]) < DAYS_IN_MONTH[to - 1]) partial.add(to);
|
||||
}
|
||||
for (const m of label.replace(MONTH_SPAN, ' ').matchAll(SINGLE_MONTH)) {
|
||||
if (isMonth(Number(m[1]))) months.add(Number(m[1]));
|
||||
}
|
||||
if (months.size > 0) {
|
||||
const sorted = [...months].sort((a, b) => a - b);
|
||||
return {
|
||||
label,
|
||||
months: sorted,
|
||||
sureMonths: sorted.filter((m) => !partial.has(m)),
|
||||
};
|
||||
}
|
||||
if (!SEASON_WORD.test(label)) return null;
|
||||
const range = SUMMER_WORD.test(label)
|
||||
? UNDATED_SUMMER
|
||||
: WINTER_WORD.test(label)
|
||||
? UNDATED_WINTER
|
||||
: UNDATED_ANY;
|
||||
return {
|
||||
label,
|
||||
months: [...range.months],
|
||||
sureMonths: [...range.sureMonths],
|
||||
};
|
||||
}
|
||||
|
||||
/** 문장 전체가 기간이면 그 기간 — "4월~11월", "동절기 (11월~2월)", "매년 12월~3월". 아니면 null */
|
||||
function periodOnly(text: string): MonthPeriod | null {
|
||||
const leftover = text
|
||||
.replace(MONTH_SPAN, ' ')
|
||||
.replace(SINGLE_MONTH, ' ')
|
||||
.replace(SEASON_WORDS, ' ')
|
||||
.replace(PERIOD_FILLER, '');
|
||||
return leftover === '' ? parsePeriod(text.trim()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉬는날의 조건·예외 문구 — 여기 나온 요일·평일은 정기 휴무가 아니다.
|
||||
* 2026-11-05 대구 관광공사 원문: "매주 월요일 (월요일이 공휴일인 경우 화요일 휴무)", "일요일을 제외한 법정공휴일",
|
||||
@@ -180,8 +346,6 @@ const REST_CONDITION =
|
||||
* ("공휴일 다음 첫 번째 평일" 의 '번째' 를 몇째 주로 읽어 매주 월요일 휴무까지 버렸다, 2026-11-05 국립대구박물관 등 4곳)
|
||||
*/
|
||||
const REST_NOT_WEEKLY = /매월|매달|격주|째|마지막\s*주|\d\s*주차/;
|
||||
/** 시기 머리말 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
|
||||
const PERIOD_HEADER = /\d{1,2}\s*월|하절기|동절기|여름|겨울|성수기|비수기/;
|
||||
/** 대괄호 없이 구절 앞에 붙인 시설 이름 — "서문시장 매월 첫째주·셋째주 일요일 / 대구서문 야시장 매주 월요일~목요일" */
|
||||
const INLINE_FACILITY = /^\s*(.{2,}?)\s*[::]?\s+(?=매주|매월|매달|격주)/;
|
||||
/** 시설 이름이 아닌 말머리 — "정기휴무 매주 월요일", "단, 매주 …", "매월 격주 일요일" */
|
||||
@@ -291,7 +455,7 @@ function restWeekdays(clauses: string[], withRanges: boolean): Set<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다.
|
||||
* 쉬는날 문장에서 정기 휴무 요일·시기별 휴무·문을 열지 않는 기간·명절 휴무·기타 안내를 뽑는다.
|
||||
* @param restField 쉬는날 원문(restdatefood 등)이면 true, 영업시간 문장 속 "월요일 휴관" 구절이면 false
|
||||
*/
|
||||
function parseRestDays(
|
||||
@@ -307,61 +471,100 @@ function parseRestDays(
|
||||
if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일');
|
||||
if (/공휴일/.test(text)) out.holidayClosures.push('공휴일');
|
||||
|
||||
if (restField) {
|
||||
// 기간만 적은 구절은 그 기간에 문을 열지 않는다는 뜻이다 — "매년 12월~3월"(상주식당), "동절기 (11월~2월)"(강변축구장),
|
||||
// "1월~2월 휴장". 대괄호 머리말("[3월~7월]")은 뒤 구절에 붙는 시기라 뺀다
|
||||
for (const clause of text.split(/\s*\/\s*/)) {
|
||||
const period = clause.includes('[')
|
||||
? null
|
||||
: periodOnly(clause.replace(CLOSED_SUFFIX, ''));
|
||||
if (period) out.closedPeriods.push(period);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = restField
|
||||
? splitRestSections(text)
|
||||
: [{ header: '', body: text }];
|
||||
const closedBySection: Set<number>[] = [];
|
||||
let notWeekly = false;
|
||||
let byPeriod = false;
|
||||
for (const { header, body } of sections) {
|
||||
const clauses = regularRestClauses(body, restField);
|
||||
// 조건·예외만 있는 묶음은 휴무 요일을 따지지 않는다
|
||||
if (clauses.length === 0) continue;
|
||||
if (PERIOD_HEADER.test(header)) byPeriod = true;
|
||||
// "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다.
|
||||
// 구절마다 본다 — "매주 일요일 / 매달 마지막주 월요일" 의 매주 일요일은 휴무다(광명반점)
|
||||
const weekly = clauses.filter((clause) => !REST_NOT_WEEKLY.test(clause));
|
||||
if (weekly.length < clauses.length) notWeekly = true;
|
||||
closedBySection.push(restWeekdays(weekly, restField));
|
||||
const days = restWeekdays(weekly, restField);
|
||||
// 시기 머리말("[3월~7월 / 9월~12월]")이면 그 시기에만 쉰다 — 날짜의 달로 고른다
|
||||
const period = header ? parsePeriod(header) : null;
|
||||
if (period) {
|
||||
out.restSeasons.push({
|
||||
...period,
|
||||
closedWeekdays: [...days].sort((a, b) => a - b),
|
||||
});
|
||||
} else {
|
||||
closedBySection.push(days);
|
||||
}
|
||||
}
|
||||
if (notWeekly) out.notes.push(`휴무: ${text}`);
|
||||
if (closedBySection.length === 0) return;
|
||||
|
||||
// 시기마다 다르면 어느 시기인지 몰라 모두 휴무로 두고 시각 판단을 하지 않는다(영업시간의 하절기·동절기와 같다).
|
||||
// 시설마다 다르면 모든 시설이 쉬는 요일만 휴무다 — 어느 시설이든 열면 영업 중이다(영업시간의 시설 묶음과 같다)
|
||||
const closed = byPeriod
|
||||
? closedBySection.flatMap((days) => [...days])
|
||||
: [...closedBySection[0]].filter((d) =>
|
||||
closedBySection.every((days) => days.has(d)),
|
||||
);
|
||||
if (byPeriod) out.seasonal = true;
|
||||
const closed = [...closedBySection[0]].filter((d) =>
|
||||
closedBySection.every((days) => days.has(d)),
|
||||
);
|
||||
for (const d of closed) {
|
||||
if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d);
|
||||
}
|
||||
out.closedWeekdays.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 시설·계절 머리말 하나에 딸린 시간들 */
|
||||
/**
|
||||
* 머리말 하나에 딸린 시간들.
|
||||
* - base: 첫 머리말 앞의 시간
|
||||
* - facility: 시설 머리말("[칠성시장]") 아래 시간
|
||||
* - season: 계절·달 머리말("[하절기(4월~10월)]", "3월~10월") 아래 시간
|
||||
*/
|
||||
type SectionKind = 'base' | 'facility' | 'season';
|
||||
|
||||
interface Section {
|
||||
kind: SectionKind;
|
||||
/** 계절·달 머리말의 기간 */
|
||||
period: MonthPeriod | null;
|
||||
/** 요일 지정이 없는(매일) 구간 */
|
||||
general: TimeRange[];
|
||||
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다 */
|
||||
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다. 빈 구간은 그 요일 휴무 */
|
||||
specific: { days: number[]; ranges: TimeRange[] }[];
|
||||
}
|
||||
|
||||
const emptySection = (): Section => ({ general: [], specific: [] });
|
||||
const newSection = (
|
||||
kind: SectionKind,
|
||||
period: MonthPeriod | null = null,
|
||||
): Section => ({ kind, period, general: [], specific: [] });
|
||||
|
||||
/** 시설·계절 머리말에서 새 묶음을 연다. 지금 묶음이 비어 있으면 그대로 쓴다 */
|
||||
function openSection(sections: Section[]): void {
|
||||
const last = sections[sections.length - 1];
|
||||
if (last.general.length > 0 || last.specific.length > 0) {
|
||||
sections.push(emptySection());
|
||||
}
|
||||
const isEmptySection = (s: Section): boolean =>
|
||||
s.general.length === 0 && s.specific.length === 0;
|
||||
|
||||
const periodKey = (p: MonthPeriod): string =>
|
||||
`${p.months.join(',')}|${p.sureMonths.join(',')}`;
|
||||
|
||||
/** 같은 기간의 계절 묶음 — "[평일] 하절기 … [주말] 하절기 …" 처럼 같은 계절이 다시 나오면 이어서 쓴다 */
|
||||
function findSeason(
|
||||
sections: Section[],
|
||||
period: MonthPeriod,
|
||||
): Section | undefined {
|
||||
const key = periodKey(period);
|
||||
return sections.find(
|
||||
(s) => s.kind === 'season' && s.period && periodKey(s.period) === key,
|
||||
);
|
||||
}
|
||||
|
||||
const emptyWeek = (): WeekHours => Array.from({ length: 7 }, () => null);
|
||||
|
||||
/** 묶음 하나의 요일별 시간표 — 매일 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다 */
|
||||
function sectionWeek(section: Section): (TimeRange[] | null)[] {
|
||||
const week: (TimeRange[] | null)[] = Array.from({ length: 7 }, () =>
|
||||
function sectionWeek(section: Section): WeekHours {
|
||||
const week: WeekHours = Array.from({ length: 7 }, () =>
|
||||
section.general.length > 0 ? [...section.general] : null,
|
||||
);
|
||||
for (const { days, ranges } of section.specific) {
|
||||
@@ -371,17 +574,16 @@ function sectionWeek(section: Section): (TimeRange[] | null)[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶음끼리는 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
|
||||
* 시간표끼리 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
|
||||
* 일요일은 두 구간 모두 연다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설을 지우지 않게 한다.
|
||||
*/
|
||||
function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
|
||||
const weeks = sections.map(sectionWeek);
|
||||
function mergeWeeks(weeks: WeekHours[]): WeekHours {
|
||||
return Array.from({ length: 7 }, (_, d) => {
|
||||
const known = weeks
|
||||
.map((w) => w[d])
|
||||
.filter((ranges): ranges is TimeRange[] => ranges !== null);
|
||||
if (known.length === 0) return null;
|
||||
if (known.length === 1) return known[0];
|
||||
if (known.length === 1) return [...known[0]];
|
||||
const seen = new Set<string>();
|
||||
return known.flat().filter((r) => {
|
||||
const key = `${r.start}-${r.end}`;
|
||||
@@ -392,16 +594,186 @@ function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 계절 묶음마다 시간표를 만든다.
|
||||
* - 첫 머리말 앞의 기본 시간은 계절 시간이 적히지 않은 요일에 쓴다("10:00~17:00 [하절기(6~8월)] 10:00~13:00")
|
||||
* - 시설 묶음("[전시실] … [카페] 10:00~23:00 (4월~11월)")은 어느 계절이든 함께 연다(어느 시설이든 열려 있으면 영업 중)
|
||||
* - 기본·시설 시간이 있으면 계절이 확실하지 않은 달에 쓸 시간표를 하나 더 둔다
|
||||
*/
|
||||
function seasonHours(sections: Section[]): SeasonHours[] {
|
||||
const shared = sections.filter((s) => s.kind !== 'season');
|
||||
const baseWeek = mergeWeeks(
|
||||
sections.filter((s) => s.kind === 'base').map(sectionWeek),
|
||||
);
|
||||
const facilityWeeks = sections
|
||||
.filter((s) => s.kind === 'facility')
|
||||
.map(sectionWeek);
|
||||
const seasons: SeasonHours[] = [];
|
||||
for (const s of sections) {
|
||||
if (s.kind !== 'season' || !s.period) continue;
|
||||
const own = sectionWeek(s).map((day, d) => day ?? baseWeek[d]);
|
||||
seasons.push({ ...s.period, week: mergeWeeks([own, ...facilityWeeks]) });
|
||||
}
|
||||
if (shared.some((s) => !isEmptySection(s))) {
|
||||
const sure = new Set(seasons.flatMap((s) => s.sureMonths));
|
||||
const possible = new Set(seasons.flatMap((s) => s.months));
|
||||
const months = ALL_MONTHS.filter((m) => !sure.has(m));
|
||||
if (months.length > 0) {
|
||||
seasons.push({
|
||||
label: '',
|
||||
months,
|
||||
sureMonths: months.filter((m) => !possible.has(m)),
|
||||
week: mergeWeeks(shared.map(sectionWeek)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return seasons;
|
||||
}
|
||||
|
||||
/** 영업시간 문장을 시설·계절 묶음으로 나눠 읽고 시간표를 만든다 */
|
||||
function parseHoursText(text: string, result: ParsedOpeningHours): void {
|
||||
const sections: Section[] = [newSection('base')];
|
||||
let current = sections[0];
|
||||
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
|
||||
let headerDays: number[] | null = null;
|
||||
|
||||
/** 머리말에서 묶음을 연다. 같은 계절 묶음이 있으면 이어 쓰고, 지금 묶음이 비어 있으면 그대로 쓴다 */
|
||||
const open = (kind: SectionKind, period: MonthPeriod | null): void => {
|
||||
const same = period ? findSeason(sections, period) : undefined;
|
||||
if (same) {
|
||||
current = same;
|
||||
} else if (isEmptySection(current)) {
|
||||
current.kind = kind;
|
||||
current.period = period;
|
||||
} else {
|
||||
current = newSection(kind, period);
|
||||
sections.push(current);
|
||||
}
|
||||
};
|
||||
/** 지금 묶음은 그대로 두고 그 기간의 계절 묶음을 찾거나 만든다 — "10:00~23:00 (4월~11월)" */
|
||||
const seasonOf = (period: MonthPeriod): Section => {
|
||||
const same = findSeason(sections, period);
|
||||
if (same) return same;
|
||||
const created = newSection('season', period);
|
||||
sections.push(created);
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
|
||||
let segment = raw.trim();
|
||||
if (!segment) continue;
|
||||
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
|
||||
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
|
||||
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
|
||||
// 계절·달 머리말("[하절기(4월~10월)]", "[1월]")이면 계절 묶음을 연다
|
||||
const period = parsePeriod(header[1].trim());
|
||||
// 달 머리말의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
|
||||
headerDays = period
|
||||
? null
|
||||
: parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
|
||||
// 요일이 아닌 머리말이면 시설 묶음을 연다
|
||||
if (period) open('season', period);
|
||||
else if (headerDays === null) open('facility', null);
|
||||
segment = segment.slice(header[0].length).trim();
|
||||
if (!segment) continue;
|
||||
}
|
||||
// 줄 앞의 계절·달 머리말 — "하절기(4월~10월) 10:00~19:00", "3월~10월 09:00~18:00"
|
||||
const lead = LEADING_PERIOD.exec(segment);
|
||||
const leadPeriod = lead
|
||||
? parsePeriod(lead[0].replace(/[\s::-]+$/, ''))
|
||||
: null;
|
||||
if (lead && leadPeriod) {
|
||||
const after = segment.slice(lead[0].length).trim();
|
||||
if (!after || hasTimeRange(after)) {
|
||||
open('season', leadPeriod);
|
||||
segment = after;
|
||||
if (!segment) continue;
|
||||
} else if (CLOSED_WORDS.test(after)) {
|
||||
// "12월~2월 휴장" — 그 기간에는 문을 열지 않는다
|
||||
result.closedPeriods.push(leadPeriod);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다).
|
||||
// 괄호에 기간만 적었으면 그 구간은 그 기간의 시간이다 — "10:00~23:00 (4월~11월)"(디아크문화관)
|
||||
const parens = [...segment.matchAll(/\(([^)]*)\)/g)].map((m) => ({
|
||||
text: m[1],
|
||||
period: periodOnly(m[1]),
|
||||
}));
|
||||
const qualifier = parens.find((p) => p.period !== null)?.period ?? null;
|
||||
const target = qualifier ? seasonOf(qualifier) : current;
|
||||
const inner = parens
|
||||
.filter((p) => p.period === null)
|
||||
.flatMap((p) => p.text.split(/\s*;\s*/));
|
||||
const body = segment.replace(/\([^)]*\)/g, ' ');
|
||||
for (const part of [body, ...inner]) {
|
||||
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
|
||||
result.breaks.push(...parseRanges(part));
|
||||
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
|
||||
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
|
||||
const weekdaysOnly =
|
||||
/평일|주중/.test(part) ||
|
||||
/^(평일|주중)/.test(segment) ||
|
||||
(part === body && /\(\s*평일\s*\)/.test(segment));
|
||||
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
|
||||
continue;
|
||||
}
|
||||
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
|
||||
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
|
||||
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
|
||||
const m = SINGLE_TIME.exec(part);
|
||||
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
|
||||
continue;
|
||||
}
|
||||
const closedInHours =
|
||||
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
|
||||
if (closedInHours) parseRestDays(part, result, false);
|
||||
const ranges = parseRanges(part);
|
||||
if (ranges.length === 0) {
|
||||
// "주말 미개방" — 그 묶음(계절)에서 그 요일은 쉰다
|
||||
const closedDays = closedInHours ? null : daysClosedBy(part);
|
||||
if (closedDays) {
|
||||
target.specific.push({ days: closedDays, ranges: [] });
|
||||
continue;
|
||||
}
|
||||
if (part !== body || !closedInHours) result.notes.push(part.trim());
|
||||
continue;
|
||||
}
|
||||
const prefix = part.slice(0, part.search(/\d/));
|
||||
const days = parseDays(prefix) ?? headerDays;
|
||||
if (days) target.specific.push({ days, ranges });
|
||||
else target.general.push(...ranges);
|
||||
}
|
||||
}
|
||||
result.notes = result.notes.filter((n) => n.length > 0);
|
||||
|
||||
if (sections.some((s) => s.kind === 'season')) {
|
||||
result.seasons = seasonHours(sections);
|
||||
result.week = emptyWeek();
|
||||
return;
|
||||
}
|
||||
result.week = mergeWeeks(sections.map(sectionWeek));
|
||||
// 계절을 말하는데 기간을 읽지 못했다("여름철에는 연장") — 시간표를 비워 시각 판단을 하지 않는다
|
||||
if (SEASON_HINT.test(text)) {
|
||||
result.seasonal = true;
|
||||
result.week = emptyWeek();
|
||||
}
|
||||
}
|
||||
|
||||
function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours {
|
||||
return {
|
||||
status,
|
||||
week: Array.from({ length: 7 }, () => null),
|
||||
week: emptyWeek(),
|
||||
breaks: [],
|
||||
breaksWeekdaysOnly: false,
|
||||
lastOrder: null,
|
||||
closedWeekdays: [],
|
||||
holidayClosures: [],
|
||||
seasonal: false,
|
||||
seasons: [],
|
||||
restSeasons: [],
|
||||
closedPeriods: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
@@ -417,8 +789,7 @@ export function parseOpeningHours(
|
||||
): ParsedOpeningHours {
|
||||
const text = normalize(hours ?? '');
|
||||
const rest = normalize(restDays ?? '');
|
||||
const hasTime = TIME_RANGE.test(text);
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
const hasTime = hasTimeRange(text);
|
||||
|
||||
let result: ParsedOpeningHours;
|
||||
if (!text) {
|
||||
@@ -432,105 +803,172 @@ export function parseOpeningHours(
|
||||
);
|
||||
} else {
|
||||
result = emptyResult('parsed');
|
||||
// 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다
|
||||
result.seasonal =
|
||||
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text);
|
||||
// 시설·계절 머리말("[칠성시장]", "[하절기]")마다 묶음을 나눈다. 머리말이 없으면 묶음은 하나다
|
||||
const sections: Section[] = [emptySection()];
|
||||
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
|
||||
let headerDays: number[] | null = null;
|
||||
|
||||
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
|
||||
let segment = raw.trim();
|
||||
if (!segment) continue;
|
||||
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
|
||||
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
|
||||
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
|
||||
// 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
|
||||
headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
|
||||
// 요일이 아닌 머리말(시설·계절)이면 새 묶음을 연다
|
||||
if (headerDays === null) openSection(sections);
|
||||
segment = segment.slice(header[0].length).trim();
|
||||
if (!segment) continue;
|
||||
}
|
||||
const section = sections[sections.length - 1];
|
||||
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다)
|
||||
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) =>
|
||||
m[1].split(/\s*;\s*/),
|
||||
);
|
||||
const body = segment.replace(/\([^)]*\)/g, ' ');
|
||||
for (const part of [body, ...inner]) {
|
||||
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
|
||||
result.breaks.push(...parseRanges(part));
|
||||
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
|
||||
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
|
||||
const weekdaysOnly =
|
||||
/평일|주중/.test(part) ||
|
||||
/^(평일|주중)/.test(segment) ||
|
||||
(part === body && /\(\s*평일\s*\)/.test(segment));
|
||||
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
|
||||
continue;
|
||||
}
|
||||
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
|
||||
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
|
||||
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
|
||||
const m = SINGLE_TIME.exec(part);
|
||||
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
|
||||
continue;
|
||||
}
|
||||
const closedInHours =
|
||||
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
|
||||
if (closedInHours) parseRestDays(part, result, false);
|
||||
const ranges = parseRanges(part);
|
||||
if (ranges.length === 0) {
|
||||
if (part !== body || !closedInHours) result.notes.push(part.trim());
|
||||
continue;
|
||||
}
|
||||
const prefix = part.slice(0, part.search(/\d/));
|
||||
const days = parseDays(prefix) ?? headerDays;
|
||||
if (days) section.specific.push({ days, ranges });
|
||||
else section.general.push(...ranges);
|
||||
}
|
||||
}
|
||||
result.week = mergeSectionWeeks(sections);
|
||||
result.notes = result.notes.filter((n) => n.length > 0);
|
||||
parseHoursText(text, result);
|
||||
}
|
||||
|
||||
parseRestDays(rest, result, true);
|
||||
for (const d of result.closedWeekdays) result.week[d] = [];
|
||||
for (const d of result.closedWeekdays) {
|
||||
result.week[d] = [];
|
||||
for (const season of result.seasons) season.week[d] = [];
|
||||
}
|
||||
if (
|
||||
result.seasons.length > 0 ||
|
||||
result.restSeasons.length > 0 ||
|
||||
result.closedPeriods.length > 0
|
||||
) {
|
||||
result.seasonal = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 요일·시각에 영업 중인지 판단한다.
|
||||
* @param weekday 0=일요일 … 6=토요일
|
||||
* @param minutes 00:00 부터의 분
|
||||
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
|
||||
*/
|
||||
export function isOpenAt(
|
||||
/** 한 날짜에 쓰는 시간표 하나 */
|
||||
interface DaySchedule {
|
||||
week: WeekHours;
|
||||
/** 1년 내내 쉬는 요일 + 그 시기에 쉬는 요일 */
|
||||
closedWeekdays: number[];
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' 에서 offset 일 떨어진 날의 달·요일. 형식이 다르면 null */
|
||||
function calendarDay(
|
||||
date: string,
|
||||
offset = 0,
|
||||
): { month: number; weekday: number } | null {
|
||||
const m = ISO_DATE.exec(date);
|
||||
if (!m) return null;
|
||||
const day = new Date(
|
||||
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + offset),
|
||||
);
|
||||
return { month: day.getUTCMonth() + 1, weekday: day.getUTCDay() };
|
||||
}
|
||||
|
||||
/** 그 달에 해당할 수 있는 기간들. 그 달이 확실한 기간이 있으면 그것만 */
|
||||
function forMonth<T extends MonthPeriod>(periods: T[], month: number): T[] {
|
||||
const possible = periods.filter((p) => p.months.includes(month));
|
||||
const sure = possible.filter((p) => p.sureMonths.includes(month));
|
||||
return sure.length > 0 ? sure : possible;
|
||||
}
|
||||
|
||||
/** 그 달에 쓸 수 있는 시간표들 — 어느 계절인지 모르면 여럿, 그 달의 시간표가 없으면 빈 목록 */
|
||||
function schedulesFor(
|
||||
parsed: ParsedOpeningHours,
|
||||
month: number,
|
||||
): DaySchedule[] {
|
||||
const seasons = parsed.seasons ?? [];
|
||||
const weeks =
|
||||
seasons.length > 0
|
||||
? forMonth(seasons, month).map((s) => s.week)
|
||||
: [parsed.week];
|
||||
const restSeasons = forMonth(parsed.restSeasons ?? [], month);
|
||||
const extraClosed =
|
||||
restSeasons.length > 0 ? restSeasons.map((r) => r.closedWeekdays) : [[]];
|
||||
return weeks.flatMap((week) =>
|
||||
extraClosed.map((extra) => ({
|
||||
week,
|
||||
closedWeekdays: [...parsed.closedWeekdays, ...extra],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 해당할 수 있는 시간표들의 답이 모두 같을 때만 그 답. 다르거나 시간표가 없으면 null */
|
||||
function agree(answers: (boolean | null)[]): boolean | null {
|
||||
if (answers.length === 0) return null;
|
||||
return answers.every((a) => a === answers[0]) ? answers[0] : null;
|
||||
}
|
||||
|
||||
/** 문을 열지 않는 기간이면 닫힘, 그 기간일 수 있으면 열렸다고 단정하지 않는다 */
|
||||
function applyClosedPeriods(
|
||||
parsed: ParsedOpeningHours,
|
||||
month: number,
|
||||
open: boolean | null,
|
||||
): boolean | null {
|
||||
const periods = (parsed.closedPeriods ?? []).filter((p) =>
|
||||
p.months.includes(month),
|
||||
);
|
||||
if (periods.some((p) => p.sureMonths.includes(month))) return false;
|
||||
if (periods.length > 0 && open === true) return null;
|
||||
return open;
|
||||
}
|
||||
|
||||
/** 자정을 넘기는 영업 구간이 있는가 — 없으면 전날 영업이 오늘로 이어질 일이 없다 */
|
||||
function hasOvernight(parsed: ParsedOpeningHours): boolean {
|
||||
const weeks = [parsed.week, ...(parsed.seasons ?? []).map((s) => s.week)];
|
||||
return weeks.some((week) =>
|
||||
week.some((day) => (day ?? []).some((r) => r.end > MINUTES_PER_DAY)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 전날(weekday) 밤에 시작한 영업이 오늘 minutes 까지 이어지는가 */
|
||||
function carriesOver(
|
||||
schedule: DaySchedule,
|
||||
weekday: number,
|
||||
minutes: number,
|
||||
): boolean {
|
||||
if (schedule.closedWeekdays.includes(weekday)) return false;
|
||||
return (schedule.week[weekday] ?? []).some(
|
||||
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
|
||||
);
|
||||
}
|
||||
|
||||
/** 시간표 하나로 본 그날(weekday) minutes 의 영업 여부 */
|
||||
function openOn(
|
||||
schedule: DaySchedule,
|
||||
parsed: ParsedOpeningHours,
|
||||
weekday: number,
|
||||
minutes: number,
|
||||
): boolean | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
if (parsed.seasonal) return null;
|
||||
|
||||
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
|
||||
const yesterday = parsed.week[(weekday + 6) % 7];
|
||||
const fromYesterday = (yesterday ?? []).some(
|
||||
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
|
||||
);
|
||||
if (fromYesterday) return true;
|
||||
if (parsed.closedWeekdays.includes(weekday)) return false;
|
||||
|
||||
if (schedule.closedWeekdays.includes(weekday)) return false;
|
||||
const inBreak = parsed.breaks.some(
|
||||
(b) => minutes >= b.start && minutes < b.end,
|
||||
);
|
||||
const breakApplies =
|
||||
!parsed.breaksWeekdaysOnly || (weekday >= 1 && weekday <= 5);
|
||||
if (inBreak && breakApplies) return false;
|
||||
|
||||
const today = parsed.week[weekday];
|
||||
const today = schedule.week[weekday];
|
||||
if (today === null) return null;
|
||||
return today.some((r) => minutes >= r.start && minutes < r.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 날짜·시각에 영업 중인지 판단한다. 계절별 시간표면 날짜의 달로 고르고,
|
||||
* 어느 계절인지 달로 정할 수 없으면(달을 적지 않은 하절기·동절기의 3·10·11월) 해당할 수 있는 시간표가 모두 같은 답일 때만 답한다.
|
||||
* @param date 한국 날짜 'YYYY-MM-DD'
|
||||
* @param minutes 00:00 부터의 분
|
||||
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
|
||||
*/
|
||||
export function isOpenAt(
|
||||
parsed: ParsedOpeningHours,
|
||||
date: string,
|
||||
minutes: number,
|
||||
): boolean | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
// 계절별 시간표를 두기 전(2026-11-06)에 저장한 해석 결과 — reparse-hours 로 다시 해석하기 전까지는 판단하지 않는다
|
||||
if (parsed.seasonal && !parsed.seasons) return null;
|
||||
const today = calendarDay(date);
|
||||
const yesterday = calendarDay(date, -1);
|
||||
if (!today || !yesterday) return null;
|
||||
|
||||
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
|
||||
const carried = hasOvernight(parsed)
|
||||
? applyClosedPeriods(
|
||||
parsed,
|
||||
yesterday.month,
|
||||
agree(
|
||||
schedulesFor(parsed, yesterday.month).map((s) =>
|
||||
carriesOver(s, yesterday.weekday, minutes),
|
||||
),
|
||||
),
|
||||
)
|
||||
: false;
|
||||
if (carried === true) return true;
|
||||
const open = applyClosedPeriods(
|
||||
parsed,
|
||||
today.month,
|
||||
agree(
|
||||
schedulesFor(parsed, today.month).map((s) =>
|
||||
openOn(s, parsed, today.weekday, minutes),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (open === true) return true;
|
||||
return carried === null || open === null ? null : false;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@ WanderGo AI 코스 생성이 **조건을 지키는지**, **없는 정보를 지
|
||||
그래서 반려동물 사례 대부분이 `partial_with_notice`·`no_course` 다. 큐레이션으로 동반 카페·식당을 확인하면 기대 결과를 다시 본다.
|
||||
- **전통시장은 AI 후보가 아니다.** 쇼핑 분류라 큐레이션에서 추천(O)·확인해야 후보가 된다(장소 DB §3). 서문시장 야시장 사례(F05·G02)는 `requires` 로 표시했다.
|
||||
- **11월 대구에 축제가 없다.** 행사는 수성국제비엔날레(수성못, 10-27~11-22)와 전시뿐이다. 축제를 찾는 F01 은 '없다고 말하기'를 본다.
|
||||
- **계절별 시간표는 미확인이다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 지금 영업시간 미확인이라 문화시설이면 후보에서 빠진다. 구현 2단계에서 날짜로 고르게 고친다(설계 §3.3).
|
||||
- **계절별 시간표는 미확인이었다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 영업시간 미확인이라 문화시설이면 후보에서 빠졌다.
|
||||
2026-11-06 날짜의 달로 고르게 고쳤다(설계 §3.3). 달을 적지 않은 곳(수창청춘맨숀 등 7곳)은 11월에 두 계절 시간이 같은 시각만 판단한다.
|
||||
- 생성 사례는 모두 거른 뒤 후보가 8곳 이상 남는다(설계 §3.2–3.3 규칙으로 근사 계산).
|
||||
|
||||
정답 코스는 적지 않았습니다. 평가는 장소 DB 스냅샷 위에서 돌리므로, 정답은 "조건을 지켰는가"로 판단합니다.
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
|
||||
| 조건 | 규칙 |
|
||||
| --- | --- |
|
||||
| 영업 | 그날 요일 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt` 으로 10분 간격 확인(브레이크 포함). 휴무 요일이면 뺀다 |
|
||||
| 영업시간 미확인 | `status` 가 unknown · varies 이거나 `seasonal` 이면 미확인이다. **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 하절기·동절기처럼 계절별 시간표는 지금 미확인으로 읽힌다(AI 후보 23곳, 문화시설 10곳 — 수창청춘맨숀·대구예술발전소·향촌문화관 등). 달 범위가 적혀 있으면 생성 날짜로 고르도록 2단계에서 고친다 |
|
||||
| 영업 | 그날 날짜 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt(hours, 날짜, 분)` 으로 10분 간격 확인(브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간 포함) |
|
||||
| 영업시간 미확인 | 머무는 시간 중 `isOpenAt` 이 null 인 때가 있으면 미확인이다(`status` 가 unknown · varies 이거나, 그 요일 시간이 없거나, 계절을 정할 수 없는 달에 두 계절 시간이 다를 때). **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 계절별 시간표는 2026-11-06 부터 날짜의 달로 고른다(대구 30곳). 달을 적지 않은 하절기·동절기(7곳)는 3·10·11월에 두 계절 시간이 같은 시각만 판단한다 |
|
||||
| 공휴일 | 그날이 공휴일·명절이고 `holidayClosures` 가 있으면 뺀다. 공휴일 표는 코드 상수로 두고 해마다 갱신한다 |
|
||||
| 반려동물 (조건에 있을 때) | `not_allowed` 와 크기 불일치(`petSizes` 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 `allowed` · `partial` 만 남긴다. 야외 분류와 축제·행사는 `unknown` 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 |
|
||||
| 행사 시간 | `playtime_text` 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 |
|
||||
@@ -432,7 +432,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
|
||||
## 14. 구현 순서
|
||||
|
||||
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
|
||||
2. 순수 로직 — 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트, 계절별 시간표를 날짜로 고르기(§3.3) (키 불필요)
|
||||
2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3, 2026-11-06 반영) → 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요)
|
||||
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로)
|
||||
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
|
||||
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
|
||||
|
||||
+12
-1
@@ -293,8 +293,19 @@ node dist/commands/place-sync.js tour-kor
|
||||
— 같은 구절의 "월~금 10:00~18:00" 은 여는 요일이다. 쉬는날 원문의 여는 날 안내("화~일 운영")도 휴무로 읽지 않는다.
|
||||
- 매주가 아닌 휴무(매월·매달·격주·몇째 주·N주차·마지막 주)는 구절마다 판단해 안내로만 남긴다. 괄호 안 주기는 앞 구절을 꾸민다("매주 월요일 (매월 2주, 4주)").
|
||||
- 시설별 쉬는날("[북카페] 일요일 [다목적홀] 주말", "서문시장 매월 … / 대구서문 야시장 매주 월요일~목요일")은 모든 시설이 쉬는 요일만 휴무로 둔다.
|
||||
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다.
|
||||
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다(→ 2026-11-06 날짜로 고른다).
|
||||
- 남은 한계: 영업시간 문장 속 시설별 휴관("[휘트니스] … 매주 일요일 휴관", 엘리바덴 월성점)은 시설 전체 휴무로 읽힌다.
|
||||
- 2026-11-06 계절별 시간표를 날짜의 달로 고르게 고쳤다(`reparse-hours`, 영업 중 판단이 바뀐 곳 30곳). `isOpenAt` 은 요일 대신 한국 날짜('YYYY-MM-DD')를 받는다.
|
||||
- 계절·달 머리말마다 시간표를 따로 둔다(`seasons`) — "[하절기(4월~10월)]", "하절기(4~10월) …", "- 3월~10월 …", "[1월~4월 / 9월~12월]", "10:00~23:00 (4월~11월)".
|
||||
그때까지 미확인이던 23곳이 판단되고, 1년 내내 같은 시간으로 읽혀 겨울에 영업 중으로 잘못 보이던 7곳(대구수목원 아침 8시·달성 한일우호관 17시·상주식당 12~3월 등)이 바로잡혔다.
|
||||
- 달을 적지 않은 하절기·동절기(7곳)는 기관마다 범위가 달라(대구 원문 15곳: 3~10월·4~9월·4~10월·4~11월 등) 하절기 4~9월, 동절기 12~2월만 확실하다고 본다.
|
||||
3·10·11월에는 두 계절 시간이 같은 시각만 판단한다(수창청춘맨숀 11월 18시~19시는 미확인).
|
||||
- 머리말 없는 기본 시간은 계절 시간이 없는 달·요일에 쓰고("10:00~17:00 [하절기(6~8월)] 10:00~13:00"), 시설 머리말 시간은 어느 계절이든 함께 연다.
|
||||
- 쉬는날의 시기별 휴무는 그 시기에만(`restSeasons`), 기간만 적은 쉬는날("매년 12월~3월", "동절기 (11월~2월)", "… / 12월~2월")은 그 기간 내내 닫는다(`closedPeriods`, 상주식당 등 3곳).
|
||||
"1월 1일" 같은 날짜와 예외 문구 속 달("성수기 7월~8월 … 제외")은 기간으로 읽지 않는다. 계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관).
|
||||
- 계절을 말하는데 기간을 읽지 못하면 시간표를 비워 판단하지 않는다. 다시 해석하기 전에 저장된 결과(`seasons` 없음)도 판단하지 않는다.
|
||||
- 남은 한계: 계절 안의 시설 머리말("[하절기] [A관] … [B관] …")은 읽지 못해 미확인으로 둔다. 쉬는날의 예외 기간(도학야영장 "매주 수요일 (※ 성수기 … 제외)")은 적용하지 않는다.
|
||||
케이블카 출발 시각처럼 범위가 아닌 시각은 읽지 않는다(앞산 케이블카는 '점포별 상이').
|
||||
- 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경).
|
||||
끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다).
|
||||
업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.
|
||||
|
||||
Reference in New Issue
Block a user