fix(backend): 영업시간 파서가 붙은 구간·요일 머리말을 잘못 읽던 문제 수정

대구 관광공사 원문을 실제로 적재해 보니 형식이 제각각이라 시간표를 잘못 읽는 곳이 있었다.
예: 더현대 대구 "월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30" 을
월~목에 두 시간대, 금~일은 비어 있음으로 읽어 월요일 20:15 에 "영업 중"으로 나갔다.

- 줄바꿈을 구간 구분으로 본다(동기화가 <br> 을 줄바꿈으로 바꿔 저장하는데 공백으로 합쳐지고 있었다)
- 시각 바로 뒤에 요일·평일·주말·'[' 가 붙으면 구간을 나눈다(하이픈 시간 범위는 그대로)
- 괄호·대괄호 안의 '/' 로 구간을 자르지 않고, 괄호 안 브레이크타임·라스트오더를 각각 읽는다
- "[토요일]" 같은 대괄호 요일 머리말을 다음 줄들의 시간에 적용하고, "[1월]" 의 '월'은
  월요일로 읽지 않는다(달마다 다르면 계절별처럼 시각 판단을 하지 않는다)
- 주중·평일 줄의 브레이크타임은 평일 전용으로 본다
- htmlToText 가 잘못 쓴 </br> 도 줄바꿈으로 바꾼다

저장된 원문을 API 재호출 없이 다시 해석하는 `place:sync -- reparse-hours` 를 추가했다
(jsonb 키 순서와 무관하게 비교). 2026-11-03 대구 1,174곳 중 124곳의 시간표를 바로잡았다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 18:59:20 +09:00
parent df48ed78eb
commit 3d2cd99ef0
10 changed files with 278 additions and 21 deletions
+12 -1
View File
@@ -4,6 +4,7 @@ import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import { PlaceDedupe } from '@/modules/place/sync/place-dedupe';
import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse';
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
import {
DEFAULT_STORE_SIGNGU,
@@ -32,10 +33,18 @@ const USAGE = `사용법: place:sync <작업> [옵션]
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
status 지역별 적재 현황과 오늘 쓴 호출 수
--region=27`;
const JOBS = ['tour-kor', 'tour-pet', 'store', 'dedupe', 'status'] as const;
const JOBS = [
'tour-kor',
'tour-pet',
'store',
'dedupe',
'reparse-hours',
'status',
] as const;
type Job = (typeof JOBS)[number];
function parseArgs(argv: string[]): {
@@ -88,6 +97,8 @@ async function main(): Promise<void> {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
await app.get(PlaceHoursReparse).run();
} else {
await app.get(PlaceSyncStatus).report(region);
}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { parseOpeningHours } from '@/shared/utils/opening-hours';
import { stableStringify } from '@/shared/utils/stable-json';
import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
export interface ReparseResult {
places: number;
placesChanged: number;
curations: number;
curationsChanged: number;
byStatus: Record<string, number>;
}
/**
* 저장된 영업시간 원문을 지금의 파서로 다시 해석한다(공공데이터 API 를 다시 부르지 않는다).
* 파서를 고친 뒤 한 번 실행한다. 원본 갱신 시각(updated_at)은 건드리지 않는다.
*/
@Injectable()
export class PlaceHoursReparse {
private readonly logger = new Logger(PlaceHoursReparse.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
@InjectRepository(PlaceCuration)
private readonly curations: Repository<PlaceCuration>,
) {}
async run(): Promise<ReparseResult> {
const result: ReparseResult = {
places: 0,
placesChanged: 0,
curations: 0,
curationsChanged: 0,
byStatus: {},
};
const places = await this.places
.createQueryBuilder('p')
.select([
'p.id',
'p.publicHoursText',
'p.publicRestText',
'p.publicHours',
])
.where(
'p.public_hours_text IS NOT NULL OR p.public_rest_text IS NOT NULL',
)
.getMany();
const curations = await this.curations
.createQueryBuilder('c')
.select(['c.placeId', 'c.hoursText', 'c.restText', 'c.hours'])
.where('c.hours_text IS NOT NULL OR c.rest_text IS NOT NULL')
.getMany();
await this.places.manager.transaction(async (tx) => {
for (const p of places) {
const next = parseOpeningHours(p.publicHoursText, p.publicRestText);
result.places += 1;
result.byStatus[next.status] = (result.byStatus[next.status] ?? 0) + 1;
if (stableStringify(next) === stableStringify(p.publicHours)) continue;
await tx.query(`UPDATE place SET public_hours = $1 WHERE id = $2`, [
JSON.stringify(next),
p.id,
]);
result.placesChanged += 1;
}
for (const c of curations) {
const next = parseOpeningHours(c.hoursText, c.restText);
result.curations += 1;
if (stableStringify(next) === stableStringify(c.hours)) continue;
await tx.query(
`UPDATE place_curation SET hours = $1 WHERE place_id = $2`,
[JSON.stringify(next), c.placeId],
);
result.curationsChanged += 1;
}
});
this.logger.log(
`영업시간 다시 해석: 장소 ${result.places}곳 중 ${result.placesChanged}곳 바뀜 · 큐레이션 ${result.curations}건 중 ${result.curationsChanged}건 바뀜 · 해석 결과 ${JSON.stringify(result.byStatus)}`,
);
return result;
}
}
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import { PlaceDedupe } from './place-dedupe';
import { PlaceHoursReparse } from './place-hours-reparse';
import { PlaceSyncStatus } from './place-sync-status';
import { SyncRunRecorder } from './sync-run.recorder';
import { PublicDataApiFactory } from './public-data.factory';
@@ -16,7 +18,7 @@ import { TourPetSync } from './tour-pet.sync';
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
*/
@Module({
imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])],
imports: [TypeOrmModule.forFeature([Place, PlaceCuration, PlaceSyncRun])],
providers: [
PublicDataApiFactory,
SyncRunRecorder,
@@ -24,8 +26,16 @@ import { TourPetSync } from './tour-pet.sync';
TourPetSync,
StoreSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
],
exports: [
TourKorSync,
TourPetSync,
StoreSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
],
exports: [TourKorSync, TourPetSync, StoreSync, PlaceDedupe, PlaceSyncStatus],
})
export class PlaceSyncModule {}
@@ -8,6 +8,12 @@ describe('htmlToText', () => {
expect(htmlToText('<strong>무료</strong> 입장')).toBe('무료 입장');
});
it('닫는 형태로 잘못 쓴 줄바꿈 태그(</br>)도 줄바꿈으로 바꾼다', () => {
expect(htmlToText('평일 09:00~18:00</br>주말 09:00~17:00')).toBe(
'평일 09:00~18:00\n주말 09:00~17:00',
);
});
it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
expect(htmlToText('A &amp; B &lt;안내&gt;')).toBe('A & B <안내>');
});
+2 -2
View File
@@ -15,12 +15,12 @@ const ENTITIES: Record<string, string> = {
/**
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
* `<br>`·`</p>` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
* `<br>`·`</br>`·`</p>` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
*/
export function htmlToText(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const text = String(value)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/?br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m)
@@ -133,6 +133,78 @@ describe('parseOpeningHours', () => {
expect(p.holidayClosures).toEqual([]);
});
// 2026-11-03 대구 관광공사 원문 — 구간이 구분 없이 붙어 오거나 줄바꿈으로만 나뉜다
it('구분 없이 이어 붙은 요일별 구간을 나눠 읽는다(더현대 대구)', () => {
const p = parseOpeningHours(
'월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30',
);
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
expect(isOpenAt(p, MON, at(20, 15))).toBe(false);
expect(isOpenAt(p, SAT, at(20, 15))).toBe(true);
});
it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => {
const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00');
expect(isOpenAt(p, MON, at(17, 30))).toBe(true);
expect(isOpenAt(p, SAT, at(17, 30))).toBe(false);
});
it('줄바꿈은 구간 구분으로 본다', () => {
const p = parseOpeningHours(
'월요일~목요일 10:30~20:00\n금요일~일요일 10:30~20:30',
);
expect(p.week[FRI]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
expect(p.week[WED]).toEqual([{ start: at(10, 30), end: at(20) }]);
});
it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => {
const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)');
expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, WED, at(12))).toBe(true);
});
it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => {
const p = parseOpeningHours(
'[평일 / 일요일]\n- 11:00~17:30\n[토요일]\n- 11:00~19:00',
);
expect(p.week[MON]).toEqual([{ start: at(11), end: at(17, 30) }]);
expect(p.week[SUN]).toEqual([{ start: at(11), end: at(17, 30) }]);
expect(p.week[SAT]).toEqual([{ start: at(11), end: at(19) }]);
});
it('괄호 안의 / 로 구간을 자르지 않고, 준비 시간·마지막 주문을 각각 읽는다(갈비만)', () => {
const p = parseOpeningHours(
'주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:30)',
);
expect(p.week[MON]).toEqual([{ start: at(11, 30), end: at(22, 30) }]);
expect(p.breaks).toEqual([{ start: at(14, 30), end: at(16, 30) }]);
expect(p.lastOrder).toBe(at(21, 30));
});
it('주중 줄의 브레이크타임은 주말에 적용하지 않는다(갈비만 전체 원문)', () => {
const p = parseOpeningHours(
'주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:40)\n주말 11:30 ~ 22:30 (브레이크타임 없음 / 라스트오더 21:40)',
'연중무휴',
);
expect(p.breaksWeekdaysOnly).toBe(true);
expect(isOpenAt(p, WED, at(15))).toBe(false);
expect(isOpenAt(p, SAT, at(15))).toBe(true);
expect(p.lastOrder).toBe(at(21, 40));
});
it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => {
const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00');
expect(p.seasonal).toBe(true);
expect(isOpenAt(p, MON, at(12))).toBeNull();
});
it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => {
const p = parseOpeningHours('10:30-20:00');
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
});
it('매월 몇째 주 휴무는 요일을 막지 않고 안내로만 남긴다', () => {
const p = parseOpeningHours('10:00~20:00', '매월 둘째·넷째 주 월요일');
expect(p.closedWeekdays).toEqual([]);
+48 -16
View File
@@ -53,17 +53,35 @@ 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*분)?)/;
/**
* 구분 없이 이어 붙은 구간 — 시각 바로 뒤(공백 없이)에 요일·평일·주말·'['가 오는 곳.
* 2026-11-03 대구 관광공사 원문 33곳: "월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30", "평일 09:00~18:00주말 09:00~17:00"
*/
const SEGMENT_AFTER_TIME =
/(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g;
/** 줄바꿈 태그·글머리표·물결표 변형을 하나로 맞춘다 */
function normalize(text: string): string {
/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */
function protectInnerSlashes(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();
.replace(/\(([^()]*)\)/g, (_, s: string) => `(${s.replace(/\//g, ';')})`)
.replace(/\[([^[\]]*)\]/g, (_, s: string) => `[${s.replace(/\//g, ';')}]`);
}
/** 줄바꿈 태그·줄바꿈·글머리표·물결표 변형을 하나로 맞추고, 구간 사이에 ' / ' 를 넣는다 */
function normalize(text: string): string {
return (
protectInnerSlashes(text)
.replaceAll(BOM, '')
.replace(/<\/?br\s*\/?>/gi, ' / ')
// 줄바꿈은 구간 구분이다(동기화가 <br> 을 줄바꿈으로 바꿔 저장한다). 공백을 합치기 전에 바꾼다
.replace(/\s*\n+\s*/g, ' / ')
.replace(/[∼~〜]/g, '~')
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
.replace(SEGMENT_AFTER_TIME, '$1 / ')
.replace(/(^|\/)\s*-\s*/g, '$1')
.replace(/\s+/g, ' ')
.trim()
);
}
function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
@@ -105,7 +123,7 @@ function parseDays(prefix: string): number[] | null {
' ',
);
for (const m of withoutRanges.matchAll(
/([일월화수목금토])(?:요일)?(?=[\s·,/)]|$)/g,
/([일월화수목금토])(?:요일)?(?=[\s·,;/)\]]|$)/g,
)) {
days.add(DAY_CHARS.indexOf(m[1]));
}
@@ -173,22 +191,36 @@ export function parseOpeningHours(
);
} else {
result = emptyResult('parsed');
result.seasonal = /하절기|동절기|여름|겨울|성수기|비수기/.test(text);
// 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다
result.seasonal =
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text);
const general: TimeRange[] = [];
const specific: { days: number[]; ranges: TimeRange[] }[] = [];
// "[토요일]" 처럼 대괄호 요일 머리말만 있는 줄 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
let headerDays: number[] | null = null;
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 header = /^\[([^\]]*)\]$/.exec(segment);
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
// 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
continue;
}
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다)
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) =>
m[1].split(/\s*;\s*/),
);
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" 처럼 '평일'이 괄호로 붙은 경우도 평일 전용으로 본다
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
const weekdaysOnly =
/평일/.test(part) ||
/평일|주중/.test(part) ||
/^(평일|주중)/.test(segment) ||
(part === body && /\(\s*평일\s*\)/.test(segment));
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
continue;
@@ -209,7 +241,7 @@ export function parseOpeningHours(
continue;
}
const prefix = part.slice(0, part.search(/\d/));
const days = parseDays(prefix);
const days = parseDays(prefix) ?? headerDays;
if (days) specific.push({ days, ranges });
else general.push(...ranges);
}
@@ -0,0 +1,14 @@
import { stableStringify } from './stable-json';
describe('stableStringify', () => {
it('키 순서가 달라도 같은 문자열을 만든다(중첩·배열 포함)', () => {
const a = { status: 'parsed', week: [{ start: 1, end: 2 }], notes: [] };
const b = { notes: [], week: [{ end: 2, start: 1 }], status: 'parsed' };
expect(stableStringify(a)).toBe(stableStringify(b));
});
it('값이 다르면 다른 문자열이다', () => {
expect(stableStringify({ a: 1 })).not.toBe(stableStringify({ a: 2 }));
expect(stableStringify(null)).toBe('null');
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* 키 순서와 무관한 JSON 문자열 (순수 함수).
* PostgreSQL jsonb 는 저장할 때 키 순서를 바꾸므로, 값이 같은지 비교할 때 이 함수로 맞춘다.
*/
export function stableStringify(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (value !== null && typeof value === 'object') {
const obj = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(obj)
.sort()
.map((k) => [k, sortKeys(obj[k])]),
);
}
return value;
}