diff --git a/backend/src/shared/utils/opening-hours.spec.ts b/backend/src/shared/utils/opening-hours.spec.ts index 586daa8..58d9327 100644 --- a/backend/src/shared/utils/opening-hours.spec.ts +++ b/backend/src/shared/utils/opening-hours.spec.ts @@ -3,7 +3,9 @@ import { isOpenAt, parseOpeningHours } from './opening-hours'; // 요일 번호: 0=일요일 … 6=토요일 const SUN = 0; const MON = 1; +const TUE = 2; const WED = 3; +const THU = 4; const FRI = 5; const SAT = 6; const at = (h: number, m = 0) => h * 60 + m; @@ -313,4 +315,162 @@ describe('parseOpeningHours', () => { expect(p.week[WED]).toEqual([{ start: at(10), end: at(17) }]); expect(isOpenAt(p, 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); + const weekdays = parseOpeningHours('10:00~18:00', '매주 평일(월~금)'); + expect(weekdays.closedWeekdays).toEqual([MON, TUE, WED, THU, FRI]); + const overSunday = parseOpeningHours( + '09:00~18:00', + '매주 토요일~일요일 / 법정공휴일', + ); + expect(overSunday.closedWeekdays).toEqual([SUN, SAT]); + }); + + it('쉬는날의 주말·평일을 요일로 읽는다(대구 구 동촌역사·큰집식당)', () => { + const p = parseOpeningHours( + '09:00~18:00 (점심시간 11:30~12:30)', + '주말 / 공휴일', + ); + expect(p.closedWeekdays).toEqual([SUN, SAT]); + expect(isOpenAt(p, SAT, at(10))).toBe(false); + expect(isOpenAt(p, WED, at(10))).toBe(true); + expect(parseOpeningHours('10:30~20:30', '주말').closedWeekdays).toEqual([ + SUN, + SAT, + ]); + }); + + it('"공휴일이면 다음 첫 번째 평일 휴관" 때문에 매주 휴무를 버리지 않는다(국립대구박물관·사라온이야기마을)', () => { + const museum = parseOpeningHours( + '09:00~18:00', + '매주 월요일 (단, 월요일이 공휴일 때에는 공휴일 다음의 첫번째 평일) / 1월 1일 / 설날·추석 당일', + ); + expect(museum.closedWeekdays).toEqual([MON]); + expect(isOpenAt(museum, MON, at(12))).toBe(false); + expect(isOpenAt(museum, TUE, at(12))).toBe(true); + const village = parseOpeningHours( + '09:00~18:00', + '매주 월요일 / 1월 1일 / 설날·추석 연휴일 다음의 첫 번째 평일', + ); + expect(village.closedWeekdays).toEqual([MON]); + }); + + it('조건·예외 문구에 나온 요일은 정기 휴무가 아니다(서구어린이도서관·수성구립 용학도서관·우사랑한우마을)', () => { + const closedOf = (rest: string) => + parseOpeningHours('09:00~18:00', rest).closedWeekdays; + expect( + closedOf('매주 월요일 / 일요일을 제외한 법정공휴일 / 임시공휴일'), + ).toEqual([MON]); + expect( + closedOf('매주 월요일※ 단, 일요일은 다른 공휴일과 겹치는 경우 휴관'), + ).toEqual([MON]); + expect( + closedOf( + '매주 월요일 / 국경일 / 정부지정 법정공휴일(단, 공휴일과 일요일이 겹칠 경우 휴관)', + ), + ).toEqual([MON]); + expect( + closedOf('매주 월요일 (월요일이 공휴일인 경우 화요일 휴무)'), + ).toEqual([MON]); + expect(closedOf('매주 화요일(단, 화요일이 공휴일이면 정상 영업)')).toEqual([ + TUE, + ]); + // 조건 문구 앞의 요일은 그대로 휴무다(신전뮤지엄) + expect(closedOf('매주 월요일 / 화요일(단, 공휴일 제외)')).toEqual([ + MON, + TUE, + ]); + }); + + it('격주·몇 주차 휴무는 요일을 막지 않고 안내로만 남긴다(탑골식당·칠성종합시장)', () => { + const p = parseOpeningHours('11:00~21:00', '격주 월요일, 화요일'); + expect(p.closedWeekdays).toEqual([]); + expect(p.notes[0]).toContain('격주'); + const market = parseOpeningHours( + '09:00~19:00', + '매달 1, 3주차 일요일 휴무', + ); + expect(market.closedWeekdays).toEqual([]); + }); + + it('매주 휴무와 매주가 아닌 휴무가 함께 있으면 매주 휴무만 요일로 막는다(광명반점)', () => { + const p = parseOpeningHours( + '11:00~21:00', + '매주 일요일 / 매달 마지막주 월요일 / 설·추석 연휴', + ); + expect(p.closedWeekdays).toEqual([SUN]); + expect(p.notes[0]).toContain('마지막주 월요일'); + expect(isOpenAt(p, MON, at(12))).toBe(true); + // '정기휴무' 같은 말머리는 시설 이름이 아니다 + const labeled = parseOpeningHours( + '10:00~20:00', + '정기휴무 매주 월요일 / 임시휴무 매월 셋째 주 화요일', + ); + expect(labeled.closedWeekdays).toEqual([MON]); + }); + + it('구절 앞에 시설 이름을 붙여 쓴 쉬는날도 시설별로 본다 — 야시장 휴무로 시장 전체를 막지 않는다(서문시장)', () => { + const p = parseOpeningHours( + '09:00~18:00', + '- 서문시장 매월 첫째주·셋째주 일요일\n- 대구서문 야시장 매주 월요일~목요일', + ); + expect(p.closedWeekdays).toEqual([]); + expect(p.notes[0]).toContain('첫째주'); + expect(isOpenAt(p, TUE, at(12))).toBe(true); + }); + + it('괄호 안의 주기는 앞 구절을 꾸민다 — "매주 월요일 (매월 2주, 4주)" 는 매주 휴무가 아니다(대구광역시립남부도서관)', () => { + const p = parseOpeningHours( + '09:00~18:00', + '매주 월요일 (매월 2주, 4주) / 국정공휴일', + ); + expect(p.closedWeekdays).toEqual([]); + expect(p.notes[0]).toContain('매월 2주'); + }); + + it('쉬는날에 섞인 여는 날 안내는 휴무로 읽지 않는다', () => { + const closedOf = (rest: string) => + parseOpeningHours('10:00~18:00', rest).closedWeekdays; + expect(closedOf('매주 월요일 (화~일 운영)')).toEqual([MON]); + expect(closedOf('주말 (평일 정상 운영)')).toEqual([SUN, SAT]); + expect(closedOf('화요일~일요일 운영, 월요일 휴무')).toEqual([MON]); + // 쉰다는 말은 그대로 휴무다 + expect(closedOf('매주 월요일 미운영')).toEqual([MON]); + expect(closedOf('매주 화요일 영업 안 함')).toEqual([TUE]); + }); + + it('시설마다 쉬는날이 다르면 모든 시설이 쉬는 요일만 휴무다(대구출판산업지원센터)', () => { + const p = parseOpeningHours( + '10:00~18:00', + '[북카페]- 일요일[북키즈 / 다목적홀]- 주말', + ); + expect(p.closedWeekdays).toEqual([SUN]); + expect(isOpenAt(p, SAT, at(12))).toBe(true); + }); + + 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(); + }); + + it('영업시간 문장 속 휴무 구절은 같은 구절의 여는 요일 범위를 휴무로 읽지 않는다', () => { + const p = parseOpeningHours('월~금 10:00~18:00 토요일 휴무', ''); + expect(p.closedWeekdays).toEqual([SAT]); + expect(isOpenAt(p, 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); + }); }); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts index e6c075c..6fcbd11 100644 --- a/backend/src/shared/utils/opening-hours.ts +++ b/backend/src/shared/utils/opening-hours.ts @@ -47,6 +47,11 @@ export interface ParsedOpeningHours { } const DAY_CHARS = '일월화수목금토'; +const WEEKDAYS = [1, 2, 3, 4, 5]; +const WEEKEND = [0, 6]; +/** 요일 범위 — "월요일~금요일", "화~일" */ +const DAY_RANGE = + /([일월화수목금토])(?:요일)?\s*~\s*([일월화수목금토])(?:요일)?/g; const MINUTES_PER_DAY = 1440; /** 문서 앞에 붙는 보이지 않는 문자(BOM). 소스에 직접 쓰지 않으려고 코드값으로 만든다 */ const BOM = String.fromCharCode(0xfeff); @@ -135,28 +140,26 @@ function isLunchBreak(part: string): boolean { ); } +/** 요일 범위의 처음부터 끝까지를 더한다 — "토~일" 처럼 일요일을 넘어가도 이어서 센다 */ +function addDayRange(from: string, to: string, days: Set): void { + let d = DAY_CHARS.indexOf(from); + const end = DAY_CHARS.indexOf(to); + days.add(d); + while (d !== end) { + d = (d + 1) % 7; + days.add(d); + } +} + /** 시간 앞에 붙은 요일 표현을 요일 번호 목록으로 바꾼다. 요일 표현이 없으면 null(매일) */ function parseDays(prefix: string): number[] | null { if (/매일|연중/.test(prefix)) return null; const days = new Set(); - if (/평일|주중/.test(prefix)) [1, 2, 3, 4, 5].forEach((d) => days.add(d)); - if (/주말/.test(prefix)) [0, 6].forEach((d) => days.add(d)); + if (/평일|주중/.test(prefix)) WEEKDAYS.forEach((d) => days.add(d)); + if (/주말/.test(prefix)) WEEKEND.forEach((d) => days.add(d)); const rest = prefix.replace(/평일|주중|주말|공휴일|휴일/g, ' '); - for (const m of rest.matchAll( - /([일월화수목금토])(?:요일)?\s*~\s*([일월화수목금토])(?:요일)?/g, - )) { - let d = DAY_CHARS.indexOf(m[1]); - const to = DAY_CHARS.indexOf(m[2]); - days.add(d); - while (d !== to) { - d = (d + 1) % 7; - days.add(d); - } - } - const withoutRanges = rest.replace( - /[일월화수목금토](?:요일)?\s*~\s*[일월화수목금토](?:요일)?/g, - ' ', - ); + for (const m of rest.matchAll(DAY_RANGE)) addDayRange(m[1], m[2], days); + const withoutRanges = rest.replace(DAY_RANGE, ' '); for (const m of withoutRanges.matchAll( /([일월화수목금토])(?:요일)?(?=[\s·,;/)\]]|$)/g, )) { @@ -165,8 +168,137 @@ function parseDays(prefix: string): number[] | null { return days.size > 0 ? [...days].sort((a, b) => a - b) : null; } -/** 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다 */ -function parseRestDays(text: string, out: ParsedOpeningHours): void { +/** + * 쉬는날의 조건·예외 문구 — 여기 나온 요일·평일은 정기 휴무가 아니다. + * 2026-11-05 대구 관광공사 원문: "매주 월요일 (월요일이 공휴일인 경우 화요일 휴무)", "일요일을 제외한 법정공휴일", + * "일요일은 다른 공휴일과 겹치는 경우 휴관", "설날·추석 연휴일 다음의 첫 번째 평일", "화요일(단, 공휴일 제외)" + */ +const REST_CONDITION = + /경우|겹치|겹칠|제외|이면|때(?:에)?는|다음(?:날|의)?\s*(?:첫\s*번?째\s*)?평일|^\s*단(?:\s|$)/; +/** + * 매주가 아닌 휴무 — "매월 둘째·넷째 일요일", "격주 월요일", "매달 1, 3주차 일요일". 조건 문구를 뗀 뒤 구절마다 본다 + * ("공휴일 다음 첫 번째 평일" 의 '번째' 를 몇째 주로 읽어 매주 월요일 휴무까지 버렸다, 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+(?=매주|매월|매달|격주)/; +/** 시설 이름이 아닌 말머리 — "정기휴무 매주 월요일", "단, 매주 …", "매월 격주 일요일" */ +const NOT_FACILITY = + /휴|정기|쉬|단\s*,|매일|매주|매월|매달|격주|연중|요일|평일|주말|공휴일|명절/; +/** + * 쉬는날에 섞인 여는 날 안내 — "화~일 운영", "(평일 정상 운영)". 휴무로 읽지 않는다. + * "미운영", "영업 안 함" 처럼 쉰다는 말은 남긴다 + */ +const REST_OPEN_INFO = + /(? s.body.replace(/[\s/]/g, '') !== ''); +} + +/** + * 쉬는날을 시설·시기 묶음으로 나눈다 — 대괄호 머리말이나 구절 앞의 시설 이름. + * 내용이 있는 묶음이 둘 미만이면 머리말 괄호만 지우고 전체를 한 묶음으로 본다("[휴관일] 매주 월요일"). + */ +function splitRestSections(text: string): RestSection[] { + const bracketed = sectionsByBrackets(text); + if (bracketed.length >= 2) return bracketed; + const labeled = sectionsByInlineLabels(text); + if (labeled.length >= 2) return labeled; + return [{ header: '', body: text.replace(/[[\]]/g, ' ') }]; +} + +/** + * 구절에서 정기 휴무를 말하는 부분만 남긴다 — 조건·예외가 시작되는 곳부터는 버리고("매주 월요일, 공휴일인 경우 다음날"), + * 쉬는날 원문이면 여는 날 안내도 뺀다(영업시간 문장 속 구절에는 "영업시간 10:00~20:00" 같은 말이 흔해 빼지 않는다) + */ +function regularPart(text: string, restField: boolean): string { + const parts = text.split(/[,;]/); + const cut = parts.findIndex((part) => REST_CONDITION.test(part)); + return (cut === -1 ? parts : parts.slice(0, cut)) + .filter((part) => !restField || !REST_OPEN_INFO.test(part)) + .join(',') + .trim(); +} + +/** + * 정기 휴무를 말하는 구절 목록. 괄호 안은 앞 구절을 꾸미는 말로 붙여 읽는다 — + * "매주 월요일 (매월 2주, 4주)" 는 매주가 아니고(대구광역시립남부도서관), "(단, 공휴일 제외)" 같은 조건은 버린다 + */ +function regularRestClauses(body: string, restField: boolean): string[] { + return body + .replace( + /\(([^()]*)\)/g, + (_, inner: string) => ` ${regularPart(inner, restField)} `, + ) + .split(/[/※;()]/) + .map((clause) => regularPart(clause, restField)) + .filter((clause) => clause.length > 0); +} + +/** + * 구절에 나온 휴무 요일. + * @param withRanges 요일 범위("월요일~금요일")·평일·주말도 읽는다. 영업시간 문장 속 구절이면 끈다 — + * "월~금 10:00~18:00 토요일 휴무" 의 월~금은 여는 요일이다 + */ +function restWeekdays(clauses: string[], withRanges: boolean): Set { + const days = new Set(); + for (const clause of clauses) { + let rest = clause; + if (withRanges) { + if (/평일|주중/.test(rest)) WEEKDAYS.forEach((d) => days.add(d)); + if (/주말/.test(rest)) WEEKEND.forEach((d) => days.add(d)); + for (const m of rest.matchAll(DAY_RANGE)) addDayRange(m[1], m[2], days); + rest = rest.replace(DAY_RANGE, ' '); + } + for (const m of rest.matchAll(/([일월화수목금토])요일/g)) { + days.add(DAY_CHARS.indexOf(m[1])); + } + } + return days; +} + +/** + * 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다. + * @param restField 쉬는날 원문(restdatefood 등)이면 true, 영업시간 문장 속 "월요일 휴관" 구절이면 false + */ +function parseRestDays( + text: string, + out: ParsedOpeningHours, + restField: boolean, +): void { if (!text || /연중\s*무휴/.test(text)) return; // '시설 점검' 같은 말에 걸리지 않게 명절 '설'만 잡는다 if (/설(?:날|\s*[·,]|\s*연휴|\s*당일|$)/.test(text)) @@ -174,13 +306,36 @@ function parseRestDays(text: string, out: ParsedOpeningHours): void { if (/추석/.test(text)) out.holidayClosures.push('추석'); if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일'); if (/공휴일/.test(text)) out.holidayClosures.push('공휴일'); - // "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다 - if (/매월|째\s*주|째/.test(text)) { - out.notes.push(`휴무: ${text}`); - return; + + 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)); } - for (const m of text.matchAll(/([일월화수목금토])요일/g)) { - const d = DAY_CHARS.indexOf(m[1]); + 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; + for (const d of closed) { if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d); } out.closedWeekdays.sort((a, b) => a - b); @@ -325,7 +480,7 @@ export function parseOpeningHours( } const closedInHours = /([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part); - if (closedInHours) parseRestDays(part, result); + if (closedInHours) parseRestDays(part, result, false); const ranges = parseRanges(part); if (ranges.length === 0) { if (part !== body || !closedInHours) result.notes.push(part.trim()); @@ -341,7 +496,7 @@ export function parseOpeningHours( result.notes = result.notes.filter((n) => n.length > 0); } - parseRestDays(rest, result); + parseRestDays(rest, result, true); for (const d of result.closedWeekdays) result.week[d] = []; return result; } diff --git a/docs/design/place-db.md b/docs/design/place-db.md index c12d10a..c9ccf6b 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -286,6 +286,15 @@ node dist/commands/place-sync.js tour-kor - '휴게시간'은 쉬는 시간으로 읽고, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분). - "금요일 / 토요일" 처럼 요일 사이의 '/' 는 나열로 읽고, "13:00~13:00" 같은 0분 구간은 버린다(24시간으로 읽지 않는다). - 남은 한계: 쉬는 시간은 시설·요일 구분 없이 하루 전체에 적용한다. 원문이 틀린 곳(팔공산 닭갈비 "준비시간(주말) 05:00~16:30")은 그대로다. +- 2026-11-05 쉬는날 해석을 고쳤다(`reparse-hours` 로 22곳 바뀜, 영업 중 표시가 틀린 17곳 포함). 쉬는날 원문은 '/'·'※'·괄호 단위 구절로 읽는다. + - 조건·예외 문구("월요일이 공휴일인 경우 화요일 휴무", "일요일을 제외한 법정공휴일", "공휴일 다음의 첫 번째 평일")의 요일은 정기 휴무로 읽지 않는다. + '첫 번째'의 '째'가 '매월 둘째 일요일' 규칙에 걸려 국립대구박물관 등 4곳은 매주 월요일 휴무까지 빠져 있었다. + - 요일 범위("월요일~목요일", "평일(월~금)")와 '평일·주말'을 요일로 읽는다. 영업시간 문장 속 "월요일 휴관" 구절에는 적용하지 않는다 + — 같은 구절의 "월~금 10:00~18:00" 은 여는 요일이다. 쉬는날 원문의 여는 날 안내("화~일 운영")도 휴무로 읽지 않는다. + - 매주가 아닌 휴무(매월·매달·격주·몇째 주·N주차·마지막 주)는 구절마다 판단해 안내로만 남긴다. 괄호 안 주기는 앞 구절을 꾸민다("매주 월요일 (매월 2주, 4주)"). + - 시설별 쉬는날("[북카페] 일요일 [다목적홀] 주말", "서문시장 매월 … / 대구서문 야시장 매주 월요일~목요일")은 모든 시설이 쉬는 요일만 휴무로 둔다. + 시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다. + - 남은 한계: 영업시간 문장 속 시설별 휴관("[휘트니스] … 매주 일요일 휴관", 엘리바덴 월성점)은 시설 전체 휴무로 읽힌다. - 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경). 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다). 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.