fix(backend): 쉬는날 문구를 잘못 읽어 영업 중 표시가 틀리던 것 수정 #8

Merged
ttipo merged 1 commits from fix/rest-day-parsing into main 2026-11-05 20:22:24 +09:00
3 changed files with 351 additions and 27 deletions
@@ -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);
});
});
+182 -27
View File
@@ -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<number>): 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<number>();
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*(?:안|않|하지|중단|중지|없|불가))/;
/** 쉬는날의 시설·시기 머리말 하나와 그 내용 */
interface RestSection {
header: string;
body: string;
}
/** 대괄호 머리말로 나눈 묶음 — "[북카페] 일요일 [북키즈 / 다목적홀] 주말" */
function sectionsByBrackets(text: string): RestSection[] {
const pieces = text.split(/\[([^\]]*)\]/);
const sections: RestSection[] = [];
for (let i = 0; i < pieces.length; i += 2) {
const body = pieces[i].replace(/^[\s/,;~-]+|[\s/,;~-]+$/g, '');
if (body) sections.push({ header: i === 0 ? '' : pieces[i - 1], body });
}
return sections;
}
/** 구절 앞의 시설 이름으로 나눈 묶음. 시설 이름이 하나도 없으면 빈 목록 */
function sectionsByInlineLabels(text: string): RestSection[] {
const sections: RestSection[] = [{ header: '', body: '' }];
let labeled = 0;
for (const clause of text.split(/\s*\/\s*/)) {
const m = INLINE_FACILITY.exec(clause);
if (m && !NOT_FACILITY.test(m[1])) {
sections.push({ header: m[1], body: clause.slice(m[0].length) });
labeled += 1;
} else {
sections[sections.length - 1].body += ` / ${clause}`;
}
}
if (labeled === 0) return [];
return sections.filter((s) => 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<number> {
const days = new Set<number>();
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<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));
}
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;
}
+9
View File
@@ -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` 에 남긴다.