From f752bf28f5cd2c1c109c933bfbe6d0b35b5d2762 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 08:12:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EA=B3=84=EC=A0=88=EB=B3=84=20?= =?UTF-8?q?=EC=98=81=EC=97=85=EC=8B=9C=EA=B0=84=EC=9D=84=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EC=9D=98=20=EB=8B=AC=EB=A1=9C=20=EA=B3=A8=EB=9D=BC=20?= =?UTF-8?q?=EC=98=81=EC=97=85=20=EC=A4=91=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하절기·동절기처럼 계절마다 시간표가 다른 곳을 어느 계절인지 모른 채 읽고 있었다. 23곳은 영업 중인지 판단하지 못해 '영업시간 미확인'으로 나왔고(대구미술관·수창청춘맨숀·향촌문화관 등), 7곳은 두 계절 시간을 합쳐 읽어 닫혀 있는 시각에도 영업 중으로 보였다 (대구수목원 9~4월 아침 8시, 달성 한일우호관 11~2월 17시, 상주식당 12~3월 휴업 등). 영업시간 미확인은 AI 코스가 문화시설·식당·카페를 후보에서 빼는 기준이기도 하다(설계 §3.3). 시간표를 계절별로 따로 두고(seasons) 그날 날짜의 달에 맞는 것을 고른다. isOpenAt 은 요일 대신 날짜를 받는다. 시기별 쉬는 요일(restSeasons)과 문을 열지 않는 기간(closedPeriods)도 따로 읽는다. 달 없이 "하절기/동절기"만 적은 곳은 하절기 4~9월·동절기 12~2월만 확실하다고 보고, 3·10·11월에는 두 시간표의 답이 같을 때만 판단한다. 대구 원문의 계절 범위가 기관마다 3~10월·4~11월 등으로 달라서다. 개발 DB 1,117곳을 이전 파서와 비교(2026년 달마다 7일 × 30분)해 30곳의 판단이 바뀌었다. 닫힘에서 영업 중으로, 판단에서 미확인으로 바뀐 곳은 없고, 계절과 상관없는 곳의 해석도 그대로다. 이전 형식으로 저장된 계절 구분 값은 reparse-hours 로 다시 해석하기 전까지 지금처럼 미확인이다. Co-Authored-By: Claude Opus 5.5 --- backend/src/modules/place/place.service.ts | 4 +- backend/src/shared/utils/kst.spec.ts | 4 +- backend/src/shared/utils/kst.ts | 6 +- .../src/shared/utils/opening-hours.spec.ts | 325 +++++++-- backend/src/shared/utils/opening-hours.ts | 684 ++++++++++++++---- docs/ai-eval/README.md | 3 +- docs/design/ai-course.md | 6 +- docs/design/place-db.md | 13 +- 8 files changed, 858 insertions(+), 187 deletions(-) diff --git a/backend/src/modules/place/place.service.ts b/backend/src/modules/place/place.service.ts index c2f6712..f39b823 100644 --- a/backend/src/modules/place/place.service.ts +++ b/backend/src/modules/place/place.service.ts @@ -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, diff --git a/backend/src/shared/utils/kst.spec.ts b/backend/src/shared/utils/kst.spec.ts index 1e0d3cc..e4ac856 100644 --- a/backend/src/shared/utils/kst.spec.ts +++ b/backend/src/shared/utils/kst.spec.ts @@ -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, }); }); diff --git a/backend/src/shared/utils/kst.ts b/backend/src/shared/utils/kst.ts index b9c58df..c32b2bd 100644 --- a/backend/src/shared/utils/kst.ts +++ b/backend/src/shared/utils/kst.ts @@ -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(), }; } diff --git a/backend/src/shared/utils/opening-hours.spec.ts b/backend/src/shared/utils/opening-hours.spec.ts index 58d9327..d94cdb1 100644 --- a/backend/src/shared/utils/opening-hours.spec.ts +++ b/backend/src/shared/utils/opening-hours.spec.ts @@ -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(); + }); }); }); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts index 6fcbd11..47c6d03 100644 --- a/backend/src/shared/utils/opening-hours.ts +++ b/backend/src/shared/utils/opening-hours.ts @@ -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(); + const partial = new Set(); + 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 { } /** - * 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다. + * 쉬는날 문장에서 정기 휴무 요일·시기별 휴무·문을 열지 않는 기간·명절 휴무·기타 안내를 뽑는다. * @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[] = []; 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(); 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(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; +} diff --git a/docs/ai-eval/README.md b/docs/ai-eval/README.md index 7cc6a6e..57532f4 100644 --- a/docs/ai-eval/README.md +++ b/docs/ai-eval/README.md @@ -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 스냅샷 위에서 돌리므로, 정답은 "조건을 지켰는가"로 판단합니다. diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md index 9bc6faa..720bfb6 100644 --- a/docs/design/ai-course.md +++ b/docs/design/ai-course.md @@ -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, 조건 해석 diff --git a/docs/design/place-db.md b/docs/design/place-db.md index c9ccf6b..eaad7a0 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -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` 에 남긴다. -- 2.52.0