generated from ttipo/project-base
fix(backend): 쉬는날 문구를 잘못 읽어 영업 중 표시가 틀리던 것 수정 #8
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 에 남긴다.
|
||||
|
||||
Reference in New Issue
Block a user