generated from ttipo/project-base
feat(backend): 공공데이터 영업시간 문장 파서 추가
관광공사 영업시간·쉬는날은 "11:00~21:00 (준비 시간 15:30~17:00)", "화~일 10:00~18:00 (월요일 휴관)", "점포별 상이"처럼 자유 문장이라 코스 영업시간 경고와 AI 시간 검증에 바로 쓸 수 없다. 요일별 시간표·준비시간·마지막 주문·정기 휴무·명절 휴무로 해석하고, 정할 수 없는 경우(점포별 상이·계절별·값 없음)는 지어내지 않고 상태로 남긴다. 테스트는 2026-10-29 공공데이터 점검에서 받은 실제 문장으로 작성했다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { isOpenAt, parseOpeningHours } from './opening-hours';
|
||||
|
||||
// 요일 번호: 0=일요일 … 6=토요일
|
||||
const SUN = 0;
|
||||
const MON = 1;
|
||||
const WED = 3;
|
||||
const FRI = 5;
|
||||
const SAT = 6;
|
||||
const at = (h: number, m = 0) => h * 60 + m;
|
||||
|
||||
// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다
|
||||
describe('parseOpeningHours', () => {
|
||||
it('단순한 시간 구간과 연중무휴를 매일 같은 시간표로 읽는다', () => {
|
||||
const p = parseOpeningHours('11:00~22:00', '연중무휴');
|
||||
expect(p.status).toBe('parsed');
|
||||
expect(
|
||||
p.week.every((d) => d?.[0]?.start === at(11) && d?.[0]?.end === at(22)),
|
||||
).toBe(true);
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
});
|
||||
|
||||
it('글머리표와 준비시간(브레이크)을 읽는다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'- 11:00~21:00 / - 준비시간 15:00~17:00',
|
||||
'연중무휴',
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => {
|
||||
const a = parseOpeningHours(
|
||||
'11:00~21:00 (준비 시간 15:30~17:00)',
|
||||
'연중무휴',
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'- 11:30~21:00 / - 준비시간(평일) 15:30~16:30 / - 마지막 주문 20:30',
|
||||
'1월 1일 / 설·추석 연휴',
|
||||
);
|
||||
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(p.holidayClosures).toEqual(
|
||||
expect.arrayContaining(['설', '추석', '1월 1일']),
|
||||
);
|
||||
});
|
||||
|
||||
it('정기 휴무 요일은 문을 닫은 것으로 본다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'11:30~18:00 / ※ 재료 소진 시 조기 마감',
|
||||
'매주 일요일',
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(p.week[SUN]).toEqual([]);
|
||||
expect(isOpenAt(p, 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => {
|
||||
expect(parseOpeningHours('점포별 상이').status).toBe('varies');
|
||||
const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고');
|
||||
expect(p.status).toBe('varies');
|
||||
expect(isOpenAt(p, SAT, at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('값이 없으면 미확인으로 둔다', () => {
|
||||
expect(parseOpeningHours(null).status).toBe('unknown');
|
||||
expect(parseOpeningHours('').status).toBe('unknown');
|
||||
expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('줄바꿈 태그와 안내 문구를 정리한다', () => {
|
||||
const p = parseOpeningHours('10:00~17:00<br>\n※ 방문 전 연락 필수');
|
||||
expect(p.status).toBe('parsed');
|
||||
expect(p.notes).toContain('방문 전 연락 필수');
|
||||
});
|
||||
|
||||
it('쉬는날 앞의 보이지 않는 문자(BOM)를 지운다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'16:00~23:00',
|
||||
`${String.fromCharCode(0xfeff)}설·추석 당일`,
|
||||
);
|
||||
expect(p.holidayClosures).toEqual(['설', '추석']);
|
||||
});
|
||||
|
||||
it('"시설 점검" 같은 말은 설 휴무로 보지 않는다', () => {
|
||||
const p = parseOpeningHours('09:00~18:00', '시설 점검일 별도 공지');
|
||||
expect(p.holidayClosures).toEqual([]);
|
||||
});
|
||||
|
||||
it('매월 몇째 주 휴무는 요일을 막지 않고 안내로만 남긴다', () => {
|
||||
const p = parseOpeningHours('10:00~20:00', '매월 둘째·넷째 주 월요일');
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
expect(p.notes[0]).toContain('매월');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 영업시간 문장 파서 (순수 함수).
|
||||
*
|
||||
* 관광공사 TourAPI 의 영업시간·쉬는날 필드(usetime, opentimefood, restdatefood 등)는
|
||||
* 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00",
|
||||
* "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일".
|
||||
* AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다.
|
||||
* 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다.
|
||||
*/
|
||||
|
||||
/** 분 단위 시간 구간(00:00 기준). 자정을 넘기면 end 가 1440 보다 크다 */
|
||||
export interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* - parsed: 시간표를 읽었다
|
||||
* - always_open: 상시 개방·24시간
|
||||
* - varies: 점포별 상이 등 정할 수 없다
|
||||
* - unknown: 값이 없거나 읽지 못했다
|
||||
*/
|
||||
export type OpeningHoursStatus =
|
||||
| 'parsed'
|
||||
| 'always_open'
|
||||
| 'varies'
|
||||
| 'unknown';
|
||||
|
||||
export interface ParsedOpeningHours {
|
||||
status: OpeningHoursStatus;
|
||||
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
|
||||
week: (TimeRange[] | null)[];
|
||||
/** 준비시간(브레이크 타임) */
|
||||
breaks: TimeRange[];
|
||||
/** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */
|
||||
breaksWeekdaysOnly: boolean;
|
||||
/** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */
|
||||
lastOrder: number | null;
|
||||
/** 정기 휴무 요일(0=일요일) */
|
||||
closedWeekdays: number[];
|
||||
/** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */
|
||||
holidayClosures: string[];
|
||||
/** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */
|
||||
seasonal: boolean;
|
||||
/** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
const DAY_CHARS = '일월화수목금토';
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
/** 문서 앞에 붙는 보이지 않는 문자(BOM). 소스에 직접 쓰지 않으려고 코드값으로 만든다 */
|
||||
const BOM = String.fromCharCode(0xfeff);
|
||||
const TIME_RANGE =
|
||||
/(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)\s*~\s*(익일\s*)?(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/g;
|
||||
const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/;
|
||||
|
||||
/** 줄바꿈 태그·글머리표·물결표 변형을 하나로 맞춘다 */
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.replaceAll(BOM, '')
|
||||
.replace(/<br\s*\/?>/gi, ' / ')
|
||||
.replace(/[∼~〜]/g, '~')
|
||||
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
|
||||
.replace(/(^|\/|\n)\s*-\s*/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
|
||||
return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0);
|
||||
}
|
||||
|
||||
function parseRanges(segment: string): TimeRange[] {
|
||||
const ranges: TimeRange[] = [];
|
||||
for (const m of segment.matchAll(TIME_RANGE)) {
|
||||
const start = toMinutes(m[1], m[2], m[3]);
|
||||
let end = toMinutes(m[5], m[6], m[7]);
|
||||
// 닫는 시각이 여는 시각보다 이르거나 '익일'이면 다음 날로 넘긴다
|
||||
if (m[4] || end <= start) end += MINUTES_PER_DAY;
|
||||
ranges.push({ start, end });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** 시간 앞에 붙은 요일 표현을 요일 번호 목록으로 바꾼다. 요일 표현이 없으면 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));
|
||||
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 withoutRanges.matchAll(
|
||||
/([일월화수목금토])(?:요일)?(?=[\s·,/)]|$)/g,
|
||||
)) {
|
||||
days.add(DAY_CHARS.indexOf(m[1]));
|
||||
}
|
||||
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
||||
}
|
||||
|
||||
/** 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다 */
|
||||
function parseRestDays(text: string, out: ParsedOpeningHours): void {
|
||||
if (!text || /연중\s*무휴/.test(text)) return;
|
||||
// '시설 점검' 같은 말에 걸리지 않게 명절 '설'만 잡는다
|
||||
if (/설(?:날|\s*[·,]|\s*연휴|\s*당일|$)/.test(text))
|
||||
out.holidayClosures.push('설');
|
||||
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;
|
||||
}
|
||||
for (const m of text.matchAll(/([일월화수목금토])요일/g)) {
|
||||
const d = DAY_CHARS.indexOf(m[1]);
|
||||
if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d);
|
||||
}
|
||||
out.closedWeekdays.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours {
|
||||
return {
|
||||
status,
|
||||
week: Array.from({ length: 7 }, () => null),
|
||||
breaks: [],
|
||||
breaksWeekdaysOnly: false,
|
||||
lastOrder: null,
|
||||
closedWeekdays: [],
|
||||
holidayClosures: [],
|
||||
seasonal: false,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업시간·쉬는날 문장을 요일별 시간표로 바꾼다.
|
||||
* @param hours 영업시간 원문(예: opentimefood)
|
||||
* @param restDays 쉬는날 원문(예: restdatefood)
|
||||
*/
|
||||
export function parseOpeningHours(
|
||||
hours: string | null | undefined,
|
||||
restDays?: string | null,
|
||||
): ParsedOpeningHours {
|
||||
const text = normalize(hours ?? '');
|
||||
const rest = normalize(restDays ?? '');
|
||||
const hasTime = TIME_RANGE.test(text);
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
|
||||
let result: ParsedOpeningHours;
|
||||
if (!text) {
|
||||
result = emptyResult('unknown');
|
||||
} else if (!hasTime && /상시|24\s*시간|항시/.test(text)) {
|
||||
result = emptyResult('always_open');
|
||||
result.week = result.week.map(() => [{ start: 0, end: MINUTES_PER_DAY }]);
|
||||
} else if (!hasTime) {
|
||||
result = emptyResult(
|
||||
/상이|다름|다르|문의/.test(text) ? 'varies' : 'unknown',
|
||||
);
|
||||
} else {
|
||||
result = emptyResult('parsed');
|
||||
result.seasonal = /하절기|동절기|여름|겨울|성수기|비수기/.test(text);
|
||||
const general: TimeRange[] = [];
|
||||
const specific: { days: number[]; ranges: TimeRange[] }[] = [];
|
||||
|
||||
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
|
||||
const segment = raw.trim();
|
||||
if (!segment) continue;
|
||||
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다
|
||||
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].map((m) => m[1]);
|
||||
const body = segment.replace(/\([^)]*\)/g, ' ');
|
||||
for (const part of [body, ...inner]) {
|
||||
if (/준비\s*시간|브레이크|휴식\s*시간|break/i.test(part)) {
|
||||
result.breaks.push(...parseRanges(part));
|
||||
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우도 평일 전용으로 본다
|
||||
const weekdaysOnly =
|
||||
/평일/.test(part) ||
|
||||
(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);
|
||||
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);
|
||||
if (days) specific.push({ days, ranges });
|
||||
else general.push(...ranges);
|
||||
}
|
||||
}
|
||||
// 매일 적용되는 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다
|
||||
if (general.length > 0) result.week = result.week.map(() => [...general]);
|
||||
for (const { days, ranges } of specific) {
|
||||
for (const d of days) result.week[d] = [...ranges];
|
||||
}
|
||||
result.notes = result.notes.filter((n) => n.length > 0);
|
||||
}
|
||||
|
||||
parseRestDays(rest, result);
|
||||
for (const d of result.closedWeekdays) result.week[d] = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 요일·시각에 영업 중인지 판단한다.
|
||||
* @param weekday 0=일요일 … 6=토요일
|
||||
* @param minutes 00:00 부터의 분
|
||||
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
|
||||
*/
|
||||
export function isOpenAt(
|
||||
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;
|
||||
|
||||
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];
|
||||
if (today === null) return null;
|
||||
return today.some((r) => minutes >= r.start && minutes < r.end);
|
||||
}
|
||||
Reference in New Issue
Block a user