From 57f332a7317eff5e640ab2a443d85e4aefe7a032 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 5 Nov 2026 19:16:50 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=89=AC=EB=8A=94=EB=82=A0=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EC=9E=98=EB=AA=BB=20=EC=9D=BD?= =?UTF-8?q?=EC=96=B4=20=EC=98=81=EC=97=85=20=EC=A4=91=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EA=B0=80=20=ED=8B=80=EB=A6=AC=EB=8D=98=20=EA=B2=83=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관광공사 쉬는날(restdate) 원문에서 'X요일' 낱말만 골라 읽어서, 영업 중 표시가 틀린 장소가 17곳 있었다 (2026-11-05 관광공사 상세 26곳을 받은 뒤 점검). - "공휴일이면 다음 첫 번째 평일 휴관" 의 '번째' 가 '매월 둘째 일요일' 규칙에 걸려 매주 월요일 휴무까지 빠졌다 (국립대구박물관 등 4곳이 월요일에 영업 중으로 보였다) - "월요일~목요일" 은 양 끝 요일만 읽고, "주말"·"평일" 은 아예 읽지 않았다(8곳) - "일요일을 제외한 공휴일", "월요일이 공휴일인 경우 화요일 휴무" 처럼 조건 속 요일을 정기 휴무로 읽었다(5곳) 쉬는날 원문을 '/'·'※'·괄호 단위 구절로 나눠, 조건·예외 문구는 버리고 요일 범위·평일·주말까지 읽는다. 매주가 아닌 휴무(매월·격주·N주차 등)는 구절마다 판단해 안내로만 남기고, 시설별 쉬는날은 모든 시설이 쉬는 요일만 휴무로 둔다. 시기별 쉬는날은 어느 시기인지 몰라 계절 구분으로 두고 영업 중 판단을 하지 않는다. 영업시간 문장 속 "월요일 휴관" 구절에는 범위·평일을 적용하지 않는다 — 같은 구절의 "월~금 10:00~18:00" 은 여는 요일이다. reparse-hours 로 22곳이 바뀌었고 모두 원문과 대조했다(위 17곳 + 격주·매달 휴무 4곳 + 시기별 1곳). Co-Authored-By: Claude Opus 5.5 --- .../src/shared/utils/opening-hours.spec.ts | 160 ++++++++++++++ backend/src/shared/utils/opening-hours.ts | 209 +++++++++++++++--- docs/design/place-db.md | 9 + 3 files changed, 351 insertions(+), 27 deletions(-) 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` 에 남긴다.