generated from ttipo/project-base
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:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user