generated from ttipo/project-base
feat(backend): 관광공사 장소·반려동물 동기화 명령 추가
`npm run place:sync -- tour-kor|tour-pet|status` 로 관광공사 국문 장소와 반려동물 동반 정보를 장소 DB 에 적재한다. API 서버가 아니라 CLI(운영은 cron)로 돈다. - tour-kor: 시·도 지역 목록 전체 → place upsert → 상세(개요·영업시간·요금·주차). 개발 계정 한도(오퍼레이션별 하루 1,000건)에 닿으면 멈추고 detail_synced_at 으로 다음 실행이 이어 받는다. 서버의 한도 초과 응답(코드 22)도 정상 멈춤으로 본다. 수정일이 바뀐 장소만 상세를 다시 받고, 목록에서 사라진 장소는 숨긴다 - tour-pet: detailPetTour2 전체(약 10회)를 받아 전구역/일부구역으로 요약 - 분류 대응을 「신분류체계 연계 정의서」 소분류 240개로 확정하고 'other' 분류 추가 - 키는 어떤 오류 메시지에도 남기지 않는다(원문·URL 인코딩 모두 가림) CHECK 제약 변경은 migration:generate 가 감지하지 못해 마이그레이션에 직접 적었다. 2026-11-02 대구 실측: 목록 1,193건 → 장소 1,174곳, 상세 535곳(영업시간 해석 92%), 반려동물 정보 288곳(그중 쇼핑 매장 275곳). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ JWT_REFRESH_EXPIRES_IN=14d
|
||||
# 한 키로 활용신청이 승인된 모든 API(관광공사 국문·영문·반려동물, 기상청 단기예보, 상가정보)를 호출한다.
|
||||
# 개발 계정은 관광공사 API 하루 1,000건 제한이 있다.
|
||||
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
|
||||
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
|
||||
# 운영 계정 승인 후 승인된 한도로 올린다.
|
||||
TOUR_API_DAILY_LIMIT=
|
||||
|
||||
# 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다.
|
||||
# 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다.
|
||||
|
||||
@@ -16,6 +16,9 @@ JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# 공공데이터포털 일반 인증키(Decoding) — 운영 전 관광공사 API 는 활용사례를 등록해 운영 계정으로 트래픽을 늘린다.
|
||||
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
|
||||
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
|
||||
# 운영 계정 승인 후 승인된 한도로 올린다.
|
||||
TOUR_API_DAILY_LIMIT=
|
||||
|
||||
# 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지.
|
||||
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
|
||||
"migration:run": "npm run typeorm -- migration:run",
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"migration:show": "npm run typeorm -- migration:show"
|
||||
"migration:show": "npm run typeorm -- migration:show",
|
||||
"place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
|
||||
"place:sync:prod": "node dist/commands/place-sync.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { PlaceSyncModule } from '@/modules/place/sync/place-sync.module';
|
||||
import { buildDatabaseOptions } from '@/shared/config/database.config';
|
||||
import { validateEnv } from '@/shared/config/env.validation';
|
||||
|
||||
/**
|
||||
* 장소 동기화 CLI 전용 루트 모듈 — HTTP·Rate Limit·캐시 없이 설정·DB·동기화만 올린다.
|
||||
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: () => ({
|
||||
...buildDatabaseOptions(),
|
||||
autoLoadEntities: true,
|
||||
// 수천 건 upsert 의 SQL 로그는 진행 상황을 가린다
|
||||
logging: false,
|
||||
}),
|
||||
}),
|
||||
PlaceSyncModule,
|
||||
],
|
||||
})
|
||||
export class PlaceSyncCliModule {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
|
||||
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
|
||||
import {
|
||||
DEFAULT_TOUR_REGION,
|
||||
TourKorSync,
|
||||
} from '@/modules/place/sync/tour-kor.sync';
|
||||
import { TourPetSync } from '@/modules/place/sync/tour-pet.sync';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import { PlaceSyncCliModule } from './place-sync.cli.module';
|
||||
|
||||
/**
|
||||
* 장소 동기화 명령 (설계: docs/design/place-db.md §4).
|
||||
* 공공데이터 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
|
||||
*
|
||||
* 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션]
|
||||
* 운영: node dist/commands/place-sync.js <작업> [옵션] (cron)
|
||||
*/
|
||||
const USAGE = `사용법: place:sync <작업> [옵션]
|
||||
tour-kor 관광공사 국문 장소 적재·갱신 (목록 → 상세). 한도에 닿으면 멈추고, 다시 실행하면 이어서 받는다
|
||||
--region=27 법정동 시·도 코드 (기본 27 대구)
|
||||
--skip-details 목록만 갱신
|
||||
tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
|
||||
status 지역별 적재 현황과 오늘 쓴 호출 수
|
||||
--region=27`;
|
||||
|
||||
const JOBS = ['tour-kor', 'tour-pet', 'status'] as const;
|
||||
type Job = (typeof JOBS)[number];
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
job: Job | null;
|
||||
region: string;
|
||||
skipDetails: boolean;
|
||||
} {
|
||||
const [first, ...rest] = argv;
|
||||
const job = (JOBS as readonly string[]).includes(first ?? '')
|
||||
? (first as Job)
|
||||
: null;
|
||||
const regionArg = rest.find((a) => a.startsWith('--region='));
|
||||
const region = regionArg?.split('=')[1]?.trim() || DEFAULT_TOUR_REGION;
|
||||
return { job, region, skipDetails: rest.includes('--skip-details') };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { job, region, skipDetails } = parseArgs(process.argv.slice(2));
|
||||
if (!job || !/^\d{2}$/.test(region)) {
|
||||
Logger.error(USAGE, 'PlaceSync');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await NestFactory.createApplicationContext(PlaceSyncCliModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
});
|
||||
try {
|
||||
if (job === 'tour-kor') {
|
||||
await app.get(TourKorSync).run({ regionCode: region, skipDetails });
|
||||
} else if (job === 'tour-pet') {
|
||||
await app.get(TourPetSync).run();
|
||||
} else {
|
||||
await app.get(PlaceSyncStatus).report(region);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
|
||||
Logger.error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'PlaceSync',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 관광공사 동기화 준비:
|
||||
* - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요
|
||||
* - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다
|
||||
* - 분류에 'other'(교육시설·연회장 등) 추가
|
||||
*
|
||||
* ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다.
|
||||
*/
|
||||
export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface {
|
||||
name = 'AddPlaceSourceTypeAndDetailSync1793599990257';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "place" DROP COLUMN "detail_synced_at"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ export class Place {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
sourceCategory!: string | null;
|
||||
|
||||
/** 관광공사 관광타입(contenttypeid: 12 관광지·14 문화시설·39 음식점 …) — 소개정보 조회에 필요하다 */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
sourceType!: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@@ -156,6 +160,13 @@ export class Place {
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
lastSeenAt!: Date | null;
|
||||
|
||||
/**
|
||||
* 상세(개요·영업시간 등)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다.
|
||||
* 개발 계정 한도(오퍼레이션별 하루 1,000건) 때문에 초기 적재가 며칠에 나뉘므로, 이 값으로 이어서 받는다.
|
||||
*/
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
detailSyncedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다.
|
||||
* 설계: docs/design/place-db.md
|
||||
*
|
||||
* ⚠️ 목록을 바꾸면 CHECK 제약도 바뀌는데, migration:generate 는 이름이 같은 CHECK 제약의 식 변경을
|
||||
* 감지하지 못한다. 새 마이그레이션에 DROP CONSTRAINT / ADD CONSTRAINT 를 직접 적는다
|
||||
* (예: 1793599990257-AddPlaceSourceTypeAndDetailSync). 뷰 식 변경은 generate 가 감지한다.
|
||||
*/
|
||||
|
||||
/** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */
|
||||
export const PLACE_SOURCES = ['tour', 'store'] as const;
|
||||
export type PlaceSource = (typeof PLACE_SOURCES)[number];
|
||||
|
||||
/** 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6) */
|
||||
/**
|
||||
* 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6, 대응표는 sync/tour-mapper.ts).
|
||||
* other: 교육시설·연회장처럼 데이트 장소가 아닌 곳 — 검색 '전체'에만 나오고 AI 후보가 아니다.
|
||||
*/
|
||||
export const PLACE_CATEGORIES = [
|
||||
'nature',
|
||||
'attraction',
|
||||
@@ -18,6 +25,7 @@ export const PLACE_CATEGORIES = [
|
||||
'bar',
|
||||
'shopping',
|
||||
'lodging',
|
||||
'other',
|
||||
] as const;
|
||||
export type PlaceCategory = (typeof PLACE_CATEGORIES)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BudgetExceededError, CallBudget, sumCalls } from './call-budget';
|
||||
|
||||
describe('CallBudget', () => {
|
||||
it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => {
|
||||
const budget = new CallBudget(10, { detailIntro2: 7 });
|
||||
expect(budget.remaining('detailIntro2')).toBe(3);
|
||||
expect(budget.remaining('detailCommon2')).toBe(10);
|
||||
});
|
||||
|
||||
it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => {
|
||||
const budget = new CallBudget(2);
|
||||
budget.take('detailCommon2');
|
||||
budget.take('detailCommon2');
|
||||
expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError);
|
||||
expect(budget.spent()).toEqual({ detailCommon2: 2 });
|
||||
});
|
||||
|
||||
it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => {
|
||||
const budget = new CallBudget(100, { areaBasedList2: 5 });
|
||||
budget.take('areaBasedList2');
|
||||
expect(budget.spent()).toEqual({ areaBasedList2: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumCalls', () => {
|
||||
it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => {
|
||||
expect(
|
||||
sumCalls([
|
||||
{ detailIntro2: 400, areaBasedList2: 2 },
|
||||
{ detailIntro2: 300 },
|
||||
]),
|
||||
).toEqual({ detailIntro2: 700, areaBasedList2: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 관광공사 API 하루 호출 한도 관리. 개발 계정은 **오퍼레이션마다** 하루 1,000건이다.
|
||||
* 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
|
||||
*/
|
||||
|
||||
/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */
|
||||
export class BudgetExceededError extends Error {
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
readonly reason: 'local' | 'server',
|
||||
) {
|
||||
super(
|
||||
reason === 'local'
|
||||
? `관광공사 ${operation} 오늘 호출 한도에 닿았습니다`
|
||||
: `관광공사 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
|
||||
);
|
||||
this.name = 'BudgetExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */
|
||||
export class CallBudget {
|
||||
private readonly used = new Map<string, number>();
|
||||
private readonly spentThisRun = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly limit: number,
|
||||
usedToday: Record<string, number> = {},
|
||||
) {
|
||||
for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n);
|
||||
}
|
||||
|
||||
remaining(operation: string): number {
|
||||
return Math.max(0, this.limit - (this.used.get(operation) ?? 0));
|
||||
}
|
||||
|
||||
canSpend(operation: string, count = 1): boolean {
|
||||
return this.remaining(operation) >= count;
|
||||
}
|
||||
|
||||
/** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */
|
||||
take(operation: string): void {
|
||||
if (!this.canSpend(operation)) {
|
||||
throw new BudgetExceededError(operation, 'local');
|
||||
}
|
||||
this.used.set(operation, (this.used.get(operation) ?? 0) + 1);
|
||||
this.spentThisRun.set(
|
||||
operation,
|
||||
(this.spentThisRun.get(operation) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */
|
||||
spent(): Record<string, number> {
|
||||
return Object.fromEntries(this.spentThisRun);
|
||||
}
|
||||
}
|
||||
|
||||
/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */
|
||||
export function sumCalls(
|
||||
records: readonly Record<string, number>[],
|
||||
): Record<string, number> {
|
||||
const total: Record<string, number> = {};
|
||||
for (const rec of records) {
|
||||
for (const [op, n] of Object.entries(rec)) {
|
||||
if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Place } from '../entities/place.entity';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
import { TourApiFactory } from './tour-api.factory';
|
||||
|
||||
/** 관광공사 국문 적재에 쓰는 오퍼레이션 */
|
||||
const TOUR_KOR_OPERATIONS = [
|
||||
'areaBasedList2',
|
||||
'detailCommon2',
|
||||
'detailIntro2',
|
||||
'detailPetTour2',
|
||||
];
|
||||
|
||||
export interface PlaceSyncStatusReport {
|
||||
byStatus: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
detailPending: number;
|
||||
withPet: number;
|
||||
usedToday: Record<string, number>;
|
||||
dailyLimit: number;
|
||||
}
|
||||
|
||||
/** 적재 진행 상황 — 지역별 장소 수, 상세 남은 수, 오늘 쓴 호출 수 */
|
||||
@Injectable()
|
||||
export class PlaceSyncStatus {
|
||||
private readonly logger = new Logger(PlaceSyncStatus.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
) {}
|
||||
|
||||
async report(regionCode: string): Promise<PlaceSyncStatusReport> {
|
||||
const count = async (column: 'status' | 'category') => {
|
||||
const rows = await this.places
|
||||
.createQueryBuilder('p')
|
||||
.select(`p.${column}`, 'key')
|
||||
.addSelect('COUNT(*)', 'n')
|
||||
.where('p.source = :source', { source: 'tour' })
|
||||
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
|
||||
.groupBy(`p.${column}`)
|
||||
.getRawMany<{ key: string; n: string }>();
|
||||
return Object.fromEntries(rows.map((r) => [r.key, Number(r.n)]));
|
||||
};
|
||||
const pendingAndPet = await this.places
|
||||
.createQueryBuilder('p')
|
||||
.select(
|
||||
`COUNT(*) FILTER (WHERE p.status = 'active' AND p.detail_synced_at IS NULL)`,
|
||||
'pending',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE p.public_pet_policy IS NOT NULL)`,
|
||||
'pet',
|
||||
)
|
||||
.where('p.source = :source', { source: 'tour' })
|
||||
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
|
||||
.getRawOne<{ pending: string; pet: string }>();
|
||||
|
||||
const report: PlaceSyncStatusReport = {
|
||||
byStatus: await count('status'),
|
||||
byCategory: await count('category'),
|
||||
detailPending: Number(pendingAndPet?.pending ?? 0),
|
||||
withPet: Number(pendingAndPet?.pet ?? 0),
|
||||
usedToday: await this.recorder.usedToday(),
|
||||
dailyLimit: this.apiFactory.dailyLimit(),
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`지역 ${regionCode} 관광공사 장소 — 상태 ${JSON.stringify(report.byStatus)}`,
|
||||
);
|
||||
this.logger.log(`분류 ${JSON.stringify(report.byCategory)}`);
|
||||
this.logger.log(
|
||||
`상세 남은 장소 ${report.detailPending} · 반려동물 정보 있는 장소 ${report.withPet}`,
|
||||
);
|
||||
const used = TOUR_KOR_OPERATIONS.map(
|
||||
(op) => `${op} ${report.usedToday[op] ?? 0}/${report.dailyLimit}`,
|
||||
).join(' · ');
|
||||
this.logger.log(`오늘(KST) 이 저장소에서 쓴 호출 — ${used}`);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Place } from '../entities/place.entity';
|
||||
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
|
||||
import { PlaceSyncStatus } from './place-sync-status';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
import { TourApiFactory } from './tour-api.factory';
|
||||
import { TourKorSync } from './tour-kor.sync';
|
||||
import { TourPetSync } from './tour-pet.sync';
|
||||
|
||||
/**
|
||||
* 장소 동기화 — 공공데이터를 place 에 적재한다.
|
||||
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])],
|
||||
providers: [
|
||||
TourApiFactory,
|
||||
SyncRunRecorder,
|
||||
TourKorSync,
|
||||
TourPetSync,
|
||||
PlaceSyncStatus,
|
||||
],
|
||||
exports: [TourKorSync, TourPetSync, PlaceSyncStatus],
|
||||
})
|
||||
export class PlaceSyncModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { kstStartOfDay } from '@/shared/utils/kst';
|
||||
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
|
||||
import type { SyncJob } from '../place.constants';
|
||||
import { sumCalls } from './call-budget';
|
||||
|
||||
/** 실행 기록에 남기는 오류 수 상한 */
|
||||
const MAX_RECORDED_ERRORS = 20;
|
||||
|
||||
/** place_sync_run 기록 — 실행 시작·종료와 "오늘(KST) 이미 쓴 호출 수" */
|
||||
@Injectable()
|
||||
export class SyncRunRecorder {
|
||||
constructor(
|
||||
@InjectRepository(PlaceSyncRun)
|
||||
private readonly runs: Repository<PlaceSyncRun>,
|
||||
) {}
|
||||
|
||||
start(job: SyncJob, scope: string | null): Promise<PlaceSyncRun> {
|
||||
return this.runs.save(
|
||||
this.runs.create({ job, scope, startedAt: new Date() }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘(한국 날짜) 이 저장소에서 기록된 호출 수를 오퍼레이션별로 합친다.
|
||||
* 같은 키를 다른 도구(점검 스크립트 등)에서 쓴 호출은 여기에 없다 — 그 경우는 서버의 한도 초과 응답으로 멈춘다.
|
||||
*/
|
||||
async usedToday(now = new Date()): Promise<Record<string, number>> {
|
||||
const today = await this.runs.find({
|
||||
where: { startedAt: MoreThanOrEqual(kstStartOfDay(now)) },
|
||||
select: { calls: true },
|
||||
});
|
||||
return sumCalls(today.map((r) => r.calls));
|
||||
}
|
||||
|
||||
async finish(
|
||||
run: PlaceSyncRun,
|
||||
result: Partial<
|
||||
Pick<
|
||||
PlaceSyncRun,
|
||||
| 'fetched'
|
||||
| 'inserted'
|
||||
| 'updated'
|
||||
| 'hidden'
|
||||
| 'gone'
|
||||
| 'calls'
|
||||
| 'snapshot'
|
||||
>
|
||||
> & { errors?: string[] },
|
||||
): Promise<void> {
|
||||
await this.runs.update(run.id, {
|
||||
...result,
|
||||
errors: (result.errors ?? []).slice(0, MAX_RECORDED_ERRORS),
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
import {
|
||||
FetchLike,
|
||||
TourApiClient,
|
||||
TourApiError,
|
||||
parseTourPage,
|
||||
} from './tour-api.client';
|
||||
|
||||
const KEY = 'abc+def/ghi==';
|
||||
|
||||
const okBody = (item: unknown, totalCount = 1) =>
|
||||
JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode: '0000', resultMsg: 'OK' },
|
||||
body: { items: item === '' ? '' : { item }, totalCount },
|
||||
},
|
||||
});
|
||||
|
||||
function client(
|
||||
responses: Array<{ status?: number; body?: string; throws?: Error }>,
|
||||
budget = new CallBudget(100),
|
||||
) {
|
||||
const urls: string[] = [];
|
||||
const fetchImpl: FetchLike = (url) => {
|
||||
urls.push(url);
|
||||
const next = responses.shift();
|
||||
if (!next) throw new Error('예상보다 많이 호출했다');
|
||||
if (next.throws) return Promise.reject(next.throws);
|
||||
const status = next.status ?? 200;
|
||||
return Promise.resolve({
|
||||
ok: status < 400,
|
||||
status,
|
||||
text: () => Promise.resolve(next.body ?? ''),
|
||||
});
|
||||
};
|
||||
const api = new TourApiClient({
|
||||
serviceKey: KEY,
|
||||
budget,
|
||||
fetchImpl,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
return { api, urls, budget };
|
||||
}
|
||||
|
||||
describe('parseTourPage', () => {
|
||||
it('항목이 1건이면 객체로, 0건이면 빈 문자열로 와도 배열로 바꾼다', () => {
|
||||
expect(parseTourPage(okBody({ contentid: '1' }), 'x').items).toEqual([
|
||||
{ contentid: '1' },
|
||||
]);
|
||||
expect(parseTourPage(okBody('', 0), 'x')).toEqual({
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('결과 코드가 0000 이 아니면 오류로 본다', () => {
|
||||
const body = JSON.stringify({
|
||||
response: { header: { resultCode: '10', resultMsg: 'INVALID_REQUEST' } },
|
||||
});
|
||||
expect(() => parseTourPage(body, 'detailIntro2')).toThrow(
|
||||
/detailIntro2: 결과 코드 10 INVALID_REQUEST/,
|
||||
);
|
||||
});
|
||||
|
||||
it('한도 초과 응답(XML, 코드 22)은 한도 오류로 본다', () => {
|
||||
const xml =
|
||||
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR</returnAuthMsg><returnReasonCode>22</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
|
||||
expect(() => parseTourPage(xml, 'detailCommon2')).toThrow(
|
||||
BudgetExceededError,
|
||||
);
|
||||
});
|
||||
|
||||
it('키 오류 같은 XML 응답은 사유를 담은 오류로 본다', () => {
|
||||
const xml =
|
||||
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
|
||||
expect(() => parseTourPage(xml, 'areaBasedList2')).toThrow(
|
||||
/SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourApiClient', () => {
|
||||
it('공통 파라미터와 인코딩된 키를 붙여 호출하고 한도를 차감한다', async () => {
|
||||
const { api, urls, budget } = client([
|
||||
{ body: okBody({ contentid: '7' }) },
|
||||
]);
|
||||
const page = await api.get('detailCommon2', { contentId: 7 });
|
||||
expect(page.items[0]).toEqual({ contentid: '7' });
|
||||
const url = new URL(urls[0]);
|
||||
expect(url.pathname).toMatch(/\/KorService2\/detailCommon2$/);
|
||||
expect(url.searchParams.get('serviceKey')).toBe(KEY);
|
||||
expect(url.searchParams.get('_type')).toBe('json');
|
||||
expect(url.searchParams.get('contentId')).toBe('7');
|
||||
expect(budget.spent()).toEqual({ detailCommon2: 1 });
|
||||
});
|
||||
|
||||
it('5xx·네트워크 오류는 다시 시도하고, 재시도도 한도에서 차감한다', async () => {
|
||||
const { api, budget } = client([
|
||||
{ status: 503 },
|
||||
{ throws: new Error('socket hang up') },
|
||||
{ body: okBody({ contentid: '1' }) },
|
||||
]);
|
||||
await expect(api.get('areaBasedList2', {})).resolves.toMatchObject({
|
||||
totalCount: 1,
|
||||
});
|
||||
expect(budget.spent()).toEqual({ areaBasedList2: 3 });
|
||||
});
|
||||
|
||||
it('한도가 없으면 호출하지 않고 BudgetExceededError 를 던진다', async () => {
|
||||
const { api, urls } = client([], new CallBudget(0));
|
||||
await expect(api.get('detailIntro2', {})).rejects.toThrow(
|
||||
BudgetExceededError,
|
||||
);
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('오류 메시지에 키가 섞여도 가린다', async () => {
|
||||
// 최종 메시지는 마지막 시도의 오류로 만든다 — 그 오류에 키가 원문·인코딩 둘 다 섞인 경우
|
||||
const { api } = client([
|
||||
{ throws: new Error('connect failed') },
|
||||
{ throws: new Error('connect failed') },
|
||||
{
|
||||
throws: new Error(
|
||||
`connect failed ?serviceKey=${encodeURIComponent(KEY)} (${KEY})`,
|
||||
),
|
||||
},
|
||||
]);
|
||||
const error = await api.get('detailCommon2', {}).catch((e: Error) => e);
|
||||
expect(error).toBeInstanceOf(TourApiError);
|
||||
expect((error as Error).message).toContain('***');
|
||||
expect((error as Error).message).not.toContain(KEY);
|
||||
expect((error as Error).message).not.toContain(encodeURIComponent(KEY));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
|
||||
/** 관광공사 국문 관광정보 서비스(KorService2, 매뉴얼 v4.4) */
|
||||
export const TOUR_API_BASE_URL = 'https://apis.data.go.kr/B551011/KorService2';
|
||||
|
||||
const COMMON_PARAMS = { MobileOS: 'ETC', MobileApp: 'WanderGo', _type: 'json' };
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const MAX_ATTEMPTS = 3; // 네트워크 오류·5xx 만 다시 시도한다
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
|
||||
|
||||
/** 공공데이터포털이 한도 초과로 거부할 때의 표시(오류 코드 22) */
|
||||
const QUOTA_EXCEEDED_RE =
|
||||
/LIMITED_NUMBER_OF_SERVICE_REQUESTS|<returnReasonCode>\s*22\s*</i;
|
||||
|
||||
export type TourItem = Record<string, unknown>;
|
||||
|
||||
export interface TourPage {
|
||||
items: TourItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 동기화가 쓰는 관광공사 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
|
||||
export interface TourApi {
|
||||
get(
|
||||
operation: string,
|
||||
params: Record<string, string | number>,
|
||||
): Promise<TourPage>;
|
||||
}
|
||||
|
||||
export class TourApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TourApiError';
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init: { signal: AbortSignal },
|
||||
) => Promise<FetchResponse>;
|
||||
|
||||
export interface TourApiClientOptions {
|
||||
serviceKey: string;
|
||||
budget: CallBudget;
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 본문을 목록으로 바꾼다. 관광공사는 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다.
|
||||
* 키가 틀리면 _type=json 이어도 XML 로 답한다.
|
||||
*/
|
||||
export function parseTourPage(body: string, operation: string): TourPage {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(body);
|
||||
} catch {
|
||||
if (QUOTA_EXCEEDED_RE.test(body)) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
const reason = /<returnAuthMsg>([^<]+)</i.exec(body)?.[1];
|
||||
throw new TourApiError(
|
||||
`${operation}: JSON 이 아닌 응답${reason ? ` (${reason})` : ''}`,
|
||||
);
|
||||
}
|
||||
const response = asRecord(asRecord(data)?.response);
|
||||
const header = asRecord(response?.header);
|
||||
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
|
||||
if (code !== '' && code !== '0000') {
|
||||
if (code === '22') throw new BudgetExceededError(operation, 'server');
|
||||
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
|
||||
throw new TourApiError(`${operation}: 결과 코드 ${code} ${msg}`.trim());
|
||||
}
|
||||
const resBody = asRecord(response?.body);
|
||||
const item = asRecord(resBody?.items)?.item;
|
||||
const items = (Array.isArray(item) ? item : item ? [item] : [])
|
||||
.map(asRecord)
|
||||
.filter((x): x is TourItem => x !== undefined);
|
||||
const totalCount = Number(resBody?.totalCount ?? items.length);
|
||||
return {
|
||||
items,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관광공사 API 클라이언트. 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다),
|
||||
* **키는 어떤 오류 메시지에도 남기지 않는다.**
|
||||
*/
|
||||
export class TourApiClient implements TourApi {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
private lastCallAt = 0;
|
||||
|
||||
constructor(private readonly options: TourApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl ?? TOUR_API_BASE_URL;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
async get(
|
||||
operation: string,
|
||||
params: Record<string, string | number>,
|
||||
): Promise<TourPage> {
|
||||
const query = new URLSearchParams({
|
||||
serviceKey: this.options.serviceKey,
|
||||
...COMMON_PARAMS,
|
||||
...Object.fromEntries(
|
||||
Object.entries(params).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
});
|
||||
const url = `${this.baseUrl}/${operation}?${query.toString()}`;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
this.options.budget.take(operation);
|
||||
await this.pace();
|
||||
try {
|
||||
const res = await this.fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const body = await res.text();
|
||||
if (res.status >= 500) {
|
||||
lastError = new TourApiError(`${operation}: HTTP ${res.status}`);
|
||||
} else if (!res.ok) {
|
||||
if (QUOTA_EXCEEDED_RE.test(body)) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
throw new TourApiError(`${operation}: HTTP ${res.status}`);
|
||||
} else {
|
||||
return parseTourPage(body, operation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof BudgetExceededError ||
|
||||
error instanceof TourApiError
|
||||
) {
|
||||
throw this.mask(error);
|
||||
}
|
||||
lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
const reason =
|
||||
lastError instanceof Error ? lastError.message : String(lastError);
|
||||
throw this.mask(
|
||||
new TourApiError(`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`),
|
||||
);
|
||||
}
|
||||
|
||||
private async pace(): Promise<void> {
|
||||
const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now();
|
||||
if (wait > 0) await this.sleep(wait);
|
||||
this.lastCallAt = Date.now();
|
||||
}
|
||||
|
||||
/** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */
|
||||
private mask<E extends Error>(error: E): E {
|
||||
const key = this.options.serviceKey;
|
||||
for (const variant of [key, encodeURIComponent(key)]) {
|
||||
if (variant) error.message = error.message.split(variant).join('***');
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import { CallBudget } from './call-budget';
|
||||
import { TourApi, TourApiClient } from './tour-api.client';
|
||||
|
||||
/** 개발 계정의 오퍼레이션별 하루 한도. 운영 계정 승인 후 TOUR_API_DAILY_LIMIT 로 올린다 */
|
||||
const DEFAULT_DAILY_LIMIT = 1_000;
|
||||
/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */
|
||||
const SAFETY_MARGIN = 20;
|
||||
|
||||
/** 키·한도 설정을 읽어 관광공사 클라이언트를 만든다. 테스트에서는 이 프로바이더를 바꿔 끼운다 */
|
||||
@Injectable()
|
||||
export class TourApiFactory {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** 이번 실행에서 쓸 오퍼레이션별 상한(하루 한도 − 여유분) */
|
||||
dailyLimit(): number {
|
||||
const limit = parsePositiveInt(
|
||||
this.config.get<string>('TOUR_API_DAILY_LIMIT'),
|
||||
DEFAULT_DAILY_LIMIT,
|
||||
);
|
||||
return Math.max(0, limit - SAFETY_MARGIN);
|
||||
}
|
||||
|
||||
create(budget: CallBudget): TourApi {
|
||||
const key = this.config.get<string>('PUBLIC_DATA_SERVICE_KEY')?.trim();
|
||||
if (!key || /change[-_]?me/i.test(key)) {
|
||||
// 키 값은 어떤 경우에도 출력하지 않는다
|
||||
throw new Error(
|
||||
'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요',
|
||||
);
|
||||
}
|
||||
return new TourApiClient({ serviceKey: key, budget });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { Place } from '../entities/place.entity';
|
||||
import type { PlaceCategory } from '../place.constants';
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
import type { TourApi, TourItem } from './tour-api.client';
|
||||
import { TourApiFactory } from './tour-api.factory';
|
||||
import {
|
||||
INTRO_FIELDS,
|
||||
SkipReason,
|
||||
TourListPlace,
|
||||
commonToDetail,
|
||||
introToDetail,
|
||||
listItemToPlace,
|
||||
} from './tour-mapper';
|
||||
|
||||
/** 법정동 시·도 코드 — 1차 출시 지역 대구 */
|
||||
export const DEFAULT_TOUR_REGION = '27';
|
||||
|
||||
const LIST_PAGE_SIZE = 1_000; // 2026-11-02 대구 1,193건을 2쪽으로 받음
|
||||
const MAX_LIST_PAGES = 50;
|
||||
const UPSERT_CHUNK = 200;
|
||||
/** 목록이 기존보다 이만큼 적게 오면 원본 장애로 보고 숨김 처리를 하지 않는다 */
|
||||
const MIN_COMPLETE_RATIO = 0.8;
|
||||
/** 상세 조회가 연속으로 이만큼 실패하면 멈춘다(키·서버 문제일 가능성) */
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
/** 상세를 받는 순서 — 한도 때문에 나뉘어도 앱에 먼저 필요한 분류부터 채운다 */
|
||||
const DETAIL_PRIORITY: PlaceCategory[] = [
|
||||
'nature',
|
||||
'attraction',
|
||||
'culture',
|
||||
'cafe',
|
||||
'food',
|
||||
'leisure',
|
||||
'bar',
|
||||
'shopping',
|
||||
'other',
|
||||
'lodging',
|
||||
];
|
||||
|
||||
/** 목록 upsert 때 덮어쓰는 컬럼(상세·큐레이션 관련 컬럼은 건드리지 않는다) */
|
||||
const LIST_PROPS = [
|
||||
'sourceType',
|
||||
'category',
|
||||
'sourceCategory',
|
||||
'name',
|
||||
'addressRoad',
|
||||
'lat',
|
||||
'lng',
|
||||
'ldongRegnCd',
|
||||
'ldongSignguCd',
|
||||
'phone',
|
||||
'imageUrl',
|
||||
'imageThumbUrl',
|
||||
'imageLicense',
|
||||
'sourceModifiedAt',
|
||||
'status',
|
||||
'lastSeenAt',
|
||||
'detailSyncedAt',
|
||||
'updatedAt',
|
||||
] as const satisfies readonly (keyof Place)[];
|
||||
|
||||
export interface TourKorOptions {
|
||||
regionCode: string;
|
||||
/** 목록만 갱신하고 상세는 받지 않는다 */
|
||||
skipDetails?: boolean;
|
||||
}
|
||||
|
||||
export interface TourKorResult {
|
||||
fetched: number;
|
||||
totalCount: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
hidden: number;
|
||||
skipped: Partial<Record<SkipReason, number>>;
|
||||
detailed: number;
|
||||
detailFailed: number;
|
||||
detailRemaining: number;
|
||||
stoppedByBudget: boolean;
|
||||
calls: Record<string, number>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관광공사 국문 장소 동기화(초기 적재 겸 전체 갱신).
|
||||
* 1) 시·도 지역 목록을 전부 받아 place 에 upsert — 바뀐 장소는 상세를 다시 받도록 표시
|
||||
* 2) 상세(개요·영업시간)를 한도 안에서 받는다. 남은 것은 다음 실행이 이어서 받는다
|
||||
* 3) 목록에서 사라진 장소는 hidden (지우지 않는다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourKorSync {
|
||||
private readonly logger = new Logger(TourKorSync.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
) {}
|
||||
|
||||
async run(options: TourKorOptions): Promise<TourKorResult> {
|
||||
const { regionCode } = options;
|
||||
const budget = new CallBudget(
|
||||
this.apiFactory.dailyLimit(),
|
||||
await this.recorder.usedToday(),
|
||||
);
|
||||
const api = this.apiFactory.create(budget);
|
||||
const run = await this.recorder.start('tour_kor', regionCode);
|
||||
const result: TourKorResult = {
|
||||
fetched: 0,
|
||||
totalCount: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
hidden: 0,
|
||||
skipped: {},
|
||||
detailed: 0,
|
||||
detailFailed: 0,
|
||||
detailRemaining: 0,
|
||||
stoppedByBudget: false,
|
||||
calls: {},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await this.syncList(api, regionCode, result);
|
||||
if (!options.skipDetails && !result.stoppedByBudget) {
|
||||
await this.syncDetails(api, budget, regionCode, result);
|
||||
}
|
||||
result.detailRemaining = await this.places.countBy({
|
||||
source: 'tour',
|
||||
ldongRegnCd: regionCode,
|
||||
status: 'active',
|
||||
detailSyncedAt: IsNull(),
|
||||
});
|
||||
} catch (error) {
|
||||
result.errors.unshift(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
result.calls = budget.spent();
|
||||
await this.recorder.finish(run, {
|
||||
fetched: result.fetched,
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
hidden: result.hidden,
|
||||
calls: result.calls,
|
||||
errors: result.errors,
|
||||
});
|
||||
this.report(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async syncList(
|
||||
api: TourApi,
|
||||
regionCode: string,
|
||||
result: TourKorResult,
|
||||
): Promise<void> {
|
||||
const runStart = new Date();
|
||||
const existing = await this.places.find({
|
||||
where: { source: 'tour' },
|
||||
select: {
|
||||
id: true,
|
||||
sourceId: true,
|
||||
sourceModifiedAt: true,
|
||||
detailSyncedAt: true,
|
||||
status: true,
|
||||
ldongRegnCd: true,
|
||||
},
|
||||
});
|
||||
const bySourceId = new Map(existing.map((p) => [p.sourceId, p]));
|
||||
|
||||
const rows: Array<TourListPlace & Partial<Place>> = [];
|
||||
let complete = false;
|
||||
for (let page = 1; page <= MAX_LIST_PAGES; page += 1) {
|
||||
let items: TourItem[];
|
||||
try {
|
||||
const res = await api.get('areaBasedList2', {
|
||||
lDongRegnCd: regionCode,
|
||||
numOfRows: LIST_PAGE_SIZE,
|
||||
pageNo: page,
|
||||
arrange: 'A', // 제목순 — 쪽을 넘기는 동안 순서가 바뀌지 않게
|
||||
});
|
||||
items = res.items;
|
||||
result.totalCount = res.totalCount;
|
||||
} catch (error) {
|
||||
if (error instanceof BudgetExceededError) {
|
||||
result.stoppedByBudget = true;
|
||||
result.errors.push(error.message);
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
result.fetched += items.length;
|
||||
for (const item of items) {
|
||||
const mapped = listItemToPlace(item);
|
||||
if ('skip' in mapped) {
|
||||
result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const prev = bySourceId.get(mapped.sourceId);
|
||||
const changed =
|
||||
!prev ||
|
||||
prev.sourceModifiedAt?.getTime() !==
|
||||
mapped.sourceModifiedAt?.getTime();
|
||||
rows.push({
|
||||
...mapped,
|
||||
ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
|
||||
status: 'active',
|
||||
lastSeenAt: runStart,
|
||||
updatedAt: runStart,
|
||||
// 원본이 바뀌었거나 새 장소면 상세를 다시 받는다
|
||||
detailSyncedAt: changed ? null : (prev?.detailSyncedAt ?? null),
|
||||
});
|
||||
if (prev) result.updated += 1;
|
||||
else result.inserted += 1;
|
||||
}
|
||||
if (items.length === 0 || result.fetched >= result.totalCount) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.upsert(rows);
|
||||
|
||||
// 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다(부분 응답으로 멀쩡한 장소를 숨기지 않게)
|
||||
const activeBefore = existing.filter(
|
||||
(p) => p.status === 'active' && p.ldongRegnCd === regionCode,
|
||||
).length;
|
||||
if (!complete) return;
|
||||
if (rows.length < activeBefore * MIN_COMPLETE_RATIO) {
|
||||
result.errors.push(
|
||||
`목록이 기존 노출 장소(${activeBefore})보다 크게 적어(${rows.length}) 숨김 처리를 건너뜀`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hidden = await this.places
|
||||
.createQueryBuilder()
|
||||
.update(Place)
|
||||
.set({ status: 'hidden' })
|
||||
.where('source = :source', { source: 'tour' })
|
||||
.andWhere('ldong_regn_cd = :region', { region: regionCode })
|
||||
.andWhere('status = :active', { active: 'active' })
|
||||
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
|
||||
runStart,
|
||||
})
|
||||
.execute();
|
||||
result.hidden = hidden.affected ?? 0;
|
||||
}
|
||||
|
||||
private async upsert(rows: Partial<Place>[]): Promise<void> {
|
||||
const overwrite = LIST_PROPS.map(
|
||||
(prop) =>
|
||||
this.places.metadata.findColumnWithPropertyName(prop)!.databaseName,
|
||||
);
|
||||
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
|
||||
await this.places
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(Place)
|
||||
.values(rows.slice(i, i + UPSERT_CHUNK))
|
||||
.orUpdate(overwrite, ['source', 'source_id'])
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDetails(
|
||||
api: TourApi,
|
||||
budget: CallBudget,
|
||||
regionCode: string,
|
||||
result: TourKorResult,
|
||||
): Promise<void> {
|
||||
const pending = await this.places.find({
|
||||
where: {
|
||||
source: 'tour',
|
||||
ldongRegnCd: regionCode,
|
||||
status: 'active',
|
||||
detailSyncedAt: IsNull(),
|
||||
},
|
||||
select: { id: true, sourceId: true, sourceType: true, category: true },
|
||||
});
|
||||
pending.sort(
|
||||
(a, b) =>
|
||||
DETAIL_PRIORITY.indexOf(a.category) -
|
||||
DETAIL_PRIORITY.indexOf(b.category),
|
||||
);
|
||||
|
||||
let consecutiveFailures = 0;
|
||||
for (const place of pending) {
|
||||
const typeId = place.sourceType ?? '';
|
||||
const needIntro = typeId in INTRO_FIELDS;
|
||||
if (
|
||||
!budget.canSpend('detailCommon2') ||
|
||||
(needIntro && !budget.canSpend('detailIntro2'))
|
||||
) {
|
||||
result.stoppedByBudget = true;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const common = await api.get('detailCommon2', {
|
||||
contentId: place.sourceId,
|
||||
});
|
||||
const intro = needIntro
|
||||
? await api.get('detailIntro2', {
|
||||
contentId: place.sourceId,
|
||||
contentTypeId: typeId,
|
||||
})
|
||||
: null;
|
||||
await this.places.update(place.id, {
|
||||
...commonToDetail(common.items[0] ?? {}),
|
||||
...(intro ? introToDetail(typeId, intro.items[0] ?? {}) : {}),
|
||||
detailSyncedAt: new Date(),
|
||||
});
|
||||
result.detailed += 1;
|
||||
consecutiveFailures = 0;
|
||||
} catch (error) {
|
||||
if (error instanceof BudgetExceededError) {
|
||||
result.stoppedByBudget = true;
|
||||
result.errors.push(error.message);
|
||||
break;
|
||||
}
|
||||
result.detailFailed += 1;
|
||||
consecutiveFailures += 1;
|
||||
result.errors.push(
|
||||
`${place.sourceId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
result.errors.push(
|
||||
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private report(r: TourKorResult): void {
|
||||
const skipped = Object.entries(r.skipped)
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(', ');
|
||||
this.logger.log(
|
||||
`목록 ${r.fetched}/${r.totalCount}건 → 새 장소 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
|
||||
(skipped ? ` · 건너뜀(${skipped})` : ''),
|
||||
);
|
||||
this.logger.log(
|
||||
`상세 ${r.detailed}건 받음 · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
|
||||
(r.stoppedByBudget
|
||||
? ' — 오늘 한도에 닿아 멈춤, 내일 같은 명령을 다시 실행하면 이어서 받는다'
|
||||
: ''),
|
||||
);
|
||||
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
|
||||
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
commonToDetail,
|
||||
introToDetail,
|
||||
listItemToPlace,
|
||||
petToDetail,
|
||||
toCategory,
|
||||
} from './tour-mapper';
|
||||
|
||||
// 2026-11-02 대구 후보표에서 실제로 본 항목을 줄인 것
|
||||
const item = (over: Record<string, unknown> = {}) => ({
|
||||
contentid: '126536',
|
||||
contenttypeid: '12',
|
||||
title: '대구 근대골목(근대로의 여행)',
|
||||
addr1: '대구광역시 중구 달구벌대로 2029',
|
||||
addr2: '',
|
||||
mapx: '128.5867',
|
||||
mapy: '35.8677',
|
||||
lDongRegnCd: '27',
|
||||
lDongSignguCd: '110',
|
||||
lclsSystm1: 'VE',
|
||||
lclsSystm2: 'VE04',
|
||||
lclsSystm3: 'VE040100',
|
||||
cpyrhtDivCd: 'Type1',
|
||||
firstimage: 'http://tong.visitkorea.or.kr/a.jpg',
|
||||
firstimage2: 'http://tong.visitkorea.or.kr/a_s.jpg',
|
||||
tel: '053-661-2194',
|
||||
modifiedtime: '20261015093000',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('toCategory', () => {
|
||||
it('소분류·중분류·대분류 순서로 자체 분류를 고른다', () => {
|
||||
expect(toCategory('12', 'VE', 'VE04', 'VE040300')).toBe('nature'); // 둘레길
|
||||
expect(toCategory('12', 'VE', 'VE04', 'VE040100')).toBe('attraction'); // 골목길
|
||||
expect(toCategory('12', 'VE', 'VE03', 'VE030100')).toBe('nature'); // 시민공원
|
||||
expect(toCategory('14', 'VE', 'VE07', 'VE070100')).toBe('culture'); // 박물관
|
||||
expect(toCategory('14', 'VE', 'VE09', 'VE090300')).toBe('other'); // 도서관
|
||||
expect(toCategory('39', 'FD', 'FD05', 'FD050100')).toBe('cafe');
|
||||
expect(toCategory('39', 'FD', 'FD03', 'FD030100')).toBe('cafe'); // 제과
|
||||
expect(toCategory('39', 'FD', 'FD04', 'FD040100')).toBe('bar');
|
||||
expect(toCategory('39', 'FD', 'FD01', 'FD010100')).toBe('food');
|
||||
expect(toCategory('38', 'SH', 'SH04', 'SH040300')).toBe('shopping'); // 사후면세점
|
||||
expect(toCategory('32', 'AC', 'AC06', 'AC060200')).toBe('lodging');
|
||||
expect(toCategory('12', 'NA', 'NA04', 'NA040700')).toBe('nature');
|
||||
});
|
||||
|
||||
it('축제·여행코스는 장소가 아니다', () => {
|
||||
expect(toCategory('15', 'EV', 'EV01', 'EV010200')).toBeNull();
|
||||
expect(toCategory('25', null, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('분류 코드가 비면 관광타입으로 대신한다', () => {
|
||||
expect(toCategory('39', null, null, null)).toBe('food');
|
||||
expect(toCategory('99', null, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listItemToPlace', () => {
|
||||
it('목록 항목을 place 기본 컬럼으로 바꾼다(mapx=경도, mapy=위도)', () => {
|
||||
expect(listItemToPlace(item())).toEqual({
|
||||
source: 'tour',
|
||||
sourceId: '126536',
|
||||
sourceType: '12',
|
||||
category: 'attraction',
|
||||
sourceCategory: 'VE040100',
|
||||
name: '대구 근대골목(근대로의 여행)',
|
||||
addressRoad: '대구광역시 중구 달구벌대로 2029',
|
||||
lat: 35.8677,
|
||||
lng: 128.5867,
|
||||
ldongRegnCd: '27',
|
||||
ldongSignguCd: '110',
|
||||
phone: '053-661-2194',
|
||||
imageUrl: 'http://tong.visitkorea.or.kr/a.jpg',
|
||||
imageThumbUrl: 'http://tong.visitkorea.or.kr/a_s.jpg',
|
||||
imageLicense: 'Type1',
|
||||
sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('축제·좌표 없음·한국 밖 좌표는 건너뛴다', () => {
|
||||
expect(
|
||||
listItemToPlace(item({ contenttypeid: '15', lclsSystm1: 'EV' })),
|
||||
).toEqual({ skip: 'not_place' });
|
||||
expect(listItemToPlace(item({ mapx: '', mapy: '' }))).toEqual({
|
||||
skip: 'bad_coords',
|
||||
});
|
||||
expect(listItemToPlace(item({ mapx: '35.8', mapy: '128.5' }))).toEqual({
|
||||
skip: 'bad_coords',
|
||||
});
|
||||
});
|
||||
|
||||
it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => {
|
||||
const p = listItemToPlace(
|
||||
item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }),
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
imageLicense: null,
|
||||
addressRoad: '대구광역시 중구 달구벌대로 2029 (남산동)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commonToDetail', () => {
|
||||
it('개요의 HTML 을 지우고 홈페이지 URL 만 꺼낸다', () => {
|
||||
expect(
|
||||
commonToDetail({
|
||||
overview: '근대 건축물이 모인 골목<br>계산성당과 청라언덕',
|
||||
homepage:
|
||||
'<a href="https://www.jung.daegu.kr" target="_blank">www.jung.daegu.kr</a>',
|
||||
}),
|
||||
).toEqual({
|
||||
overview: '근대 건축물이 모인 골목\n계산성당과 청라언덕',
|
||||
homepage: 'https://www.jung.daegu.kr',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('introToDetail', () => {
|
||||
it('관광타입에 맞는 영업시간·쉬는날 필드를 읽고 해석한다(39 음식점)', () => {
|
||||
const d = introToDetail('39', {
|
||||
opentimefood: '11:00~21:00<br>(준비 시간 15:30~17:00)',
|
||||
restdatefood: '매주 월요일',
|
||||
parkingfood: '가능',
|
||||
firstmenu: '찜갈비',
|
||||
usetime: '이 값은 관광지 필드라 읽지 않는다',
|
||||
});
|
||||
expect(d.publicHoursText).toBe('11:00~21:00\n(준비 시간 15:30~17:00)');
|
||||
expect(d.publicRestText).toBe('매주 월요일');
|
||||
expect(d.publicHours?.status).toBe('parsed');
|
||||
expect(d.publicHours?.closedWeekdays).toEqual([1]);
|
||||
expect(d.publicExtra).toEqual({ parking: '가능', firstMenu: '찜갈비' });
|
||||
});
|
||||
|
||||
it('문화시설은 usetimeculture 와 이용요금을 읽는다', () => {
|
||||
const d = introToDetail('14', {
|
||||
usetimeculture: '09:00~18:00',
|
||||
restdateculture: '매주 월요일',
|
||||
usefee: '무료',
|
||||
});
|
||||
expect(d.publicHoursText).toBe('09:00~18:00');
|
||||
expect(d.publicExtra).toEqual({ fee: '무료' });
|
||||
});
|
||||
|
||||
it('값이 하나도 없으면 해석하지 않는다(미확인)', () => {
|
||||
expect(introToDetail('12', {})).toEqual({
|
||||
publicHoursText: null,
|
||||
publicRestText: null,
|
||||
publicHours: null,
|
||||
publicExtra: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('petToDetail', () => {
|
||||
it('전구역·일부구역을 요약하고 원문을 남긴다', () => {
|
||||
expect(
|
||||
petToDetail({
|
||||
contentid: '1',
|
||||
acmpyTypeCd: '전구역 동반가능',
|
||||
acmpyPsblCpam: '전 견종 동반 가능',
|
||||
acmpyNeedMtr: '목줄 착용',
|
||||
etcAcmpyInfo: '',
|
||||
}),
|
||||
).toEqual({
|
||||
publicPet: {
|
||||
acmpyTypeCd: '전구역 동반가능',
|
||||
acmpyPsblCpam: '전 견종 동반 가능',
|
||||
acmpyNeedMtr: '목줄 착용',
|
||||
},
|
||||
publicPetPolicy: 'allowed',
|
||||
});
|
||||
expect(
|
||||
petToDetail({ acmpyTypeCd: '일부구역 동반가능' }).publicPetPolicy,
|
||||
).toBe('partial');
|
||||
});
|
||||
|
||||
it('표시가 애매하면 요약하지 않는다(불가로 단정하지 않음)', () => {
|
||||
expect(petToDetail({ acmpyTypeCd: '동반불가' }).publicPetPolicy).toBeNull();
|
||||
expect(petToDetail({ acmpyPsblCpam: '소형견' }).publicPetPolicy).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
|
||||
import { parseKstTimestamp } from '@/shared/utils/kst';
|
||||
import { parseOpeningHours } from '@/shared/utils/opening-hours';
|
||||
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
|
||||
import { IMAGE_LICENSES } from '../place.constants';
|
||||
import type {
|
||||
ImageLicense,
|
||||
PlaceCategory,
|
||||
PublicPetPolicy,
|
||||
} from '../place.constants';
|
||||
import type { TourItem } from './tour-api.client';
|
||||
|
||||
/**
|
||||
* 관광공사 응답 → place 컬럼 변환 (순수 함수).
|
||||
* 분류 대응은 「신분류체계정보 관광타입정보 연계 정의서」(docs/data-openapi/) 의 소분류 240개를 보고 정했다.
|
||||
*/
|
||||
|
||||
/** 장소가 아닌 관광타입 — 15 축제·공연·행사는 festival_event, 25 여행코스는 저장하지 않는다 */
|
||||
const SKIP_TYPES = new Set(['15', '25']);
|
||||
|
||||
/** 소분류 단위 예외 */
|
||||
const CATEGORY_BY_LCLS3: Record<string, PlaceCategory> = {
|
||||
VE040300: 'nature', // 둘레길
|
||||
FD030100: 'cafe', // 제과(빵집) — 상가정보 빵/도넛과 같은 칸
|
||||
};
|
||||
|
||||
/** 중분류 단위 */
|
||||
const CATEGORY_BY_LCLS2: Record<string, PlaceCategory> = {
|
||||
VE01: 'attraction', // 랜드마크(건물·전망대·다리…)
|
||||
VE02: 'attraction', // 테마공원(테마파크·동물원·수족관…)
|
||||
VE03: 'nature', // 도시공원 — '산책·자연' 탭
|
||||
VE04: 'attraction', // 골목길·문화거리·마을관광지(둘레길은 위 예외)
|
||||
VE05: 'attraction', // 관광단지·리조트
|
||||
VE06: 'culture', // 공연장·영화관
|
||||
VE07: 'culture', // 박물관·기념관·전시관·미술관
|
||||
VE08: 'other', // 연회장
|
||||
VE09: 'other', // 문화원·도서관·학교 등 교육시설
|
||||
VE10: 'leisure', // 경기장·스포츠센터
|
||||
VE12: 'culture', // 서점·기타문화시설
|
||||
FD04: 'bar', // 바·펍·생맥주·전통주점
|
||||
FD05: 'cafe', // 카페·찻집
|
||||
};
|
||||
|
||||
/** 대분류 단위 */
|
||||
const CATEGORY_BY_LCLS1: Record<string, PlaceCategory> = {
|
||||
NA: 'nature', // 자연관광
|
||||
HS: 'attraction', // 역사관광
|
||||
VE: 'attraction', // 문화관광(위 중분류에 없는 것)
|
||||
EX: 'leisure', // 체험관광(공예·템플스테이·웰니스…)
|
||||
LS: 'leisure', // 레저스포츠
|
||||
SH: 'shopping', // 쇼핑(백화점·시장·사후면세점…)
|
||||
FD: 'food', // 음식(카페·주점·제과 제외)
|
||||
AC: 'lodging', // 숙박
|
||||
};
|
||||
|
||||
/** 분류 코드가 비었을 때 관광타입으로 대신한다 */
|
||||
const CATEGORY_BY_TYPE: Record<string, PlaceCategory> = {
|
||||
'12': 'attraction',
|
||||
'14': 'culture',
|
||||
'28': 'leisure',
|
||||
'32': 'lodging',
|
||||
'38': 'shopping',
|
||||
'39': 'food',
|
||||
};
|
||||
|
||||
/** 관광타입별 소개정보(detailIntro2) 필드. 32 숙박은 코스 장소가 아니라 MVP 에서는 부르지 않는다 */
|
||||
export const INTRO_FIELDS: Record<
|
||||
string,
|
||||
{ hours: string; rest: string; extra: Record<string, string> }
|
||||
> = {
|
||||
'12': {
|
||||
hours: 'usetime',
|
||||
rest: 'restdate',
|
||||
extra: {
|
||||
parking: 'parking',
|
||||
infocenter: 'infocenter',
|
||||
season: 'useseason',
|
||||
},
|
||||
},
|
||||
'14': {
|
||||
hours: 'usetimeculture',
|
||||
rest: 'restdateculture',
|
||||
extra: {
|
||||
fee: 'usefee',
|
||||
parking: 'parkingculture',
|
||||
parkingFee: 'parkingfee',
|
||||
infocenter: 'infocenterculture',
|
||||
spendTime: 'spendtime',
|
||||
},
|
||||
},
|
||||
'28': {
|
||||
hours: 'usetimeleports',
|
||||
rest: 'restdateleports',
|
||||
extra: {
|
||||
fee: 'usefeeleports',
|
||||
parking: 'parkingleports',
|
||||
parkingFee: 'parkingfeeleports',
|
||||
infocenter: 'infocenterleports',
|
||||
openPeriod: 'openperiod',
|
||||
reservation: 'reservation',
|
||||
},
|
||||
},
|
||||
'38': {
|
||||
hours: 'opentime',
|
||||
rest: 'restdateshopping',
|
||||
extra: {
|
||||
parking: 'parkingshopping',
|
||||
infocenter: 'infocentershopping',
|
||||
fairDay: 'fairday',
|
||||
saleItem: 'saleitem',
|
||||
},
|
||||
},
|
||||
'39': {
|
||||
hours: 'opentimefood',
|
||||
rest: 'restdatefood',
|
||||
extra: {
|
||||
parking: 'parkingfood',
|
||||
infocenter: 'infocenterfood',
|
||||
firstMenu: 'firstmenu',
|
||||
treatMenu: 'treatmenu',
|
||||
reservation: 'reservationfood',
|
||||
packing: 'packing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 한국 영역 대략값 — 좌표가 0 이거나 뒤바뀐 원본을 거른다 */
|
||||
const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 };
|
||||
|
||||
function str(item: TourItem, key: string): string | null {
|
||||
const v = item[key];
|
||||
if (typeof v !== 'string' && typeof v !== 'number') return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
|
||||
export function toCategory(
|
||||
typeId: string | null,
|
||||
lcls1: string | null,
|
||||
lcls2: string | null,
|
||||
lcls3: string | null,
|
||||
): PlaceCategory | null {
|
||||
if ((typeId && SKIP_TYPES.has(typeId)) || lcls1 === 'EV') return null;
|
||||
return (
|
||||
(lcls3 && CATEGORY_BY_LCLS3[lcls3]) ||
|
||||
(lcls2 && CATEGORY_BY_LCLS2[lcls2]) ||
|
||||
(lcls1 && CATEGORY_BY_LCLS1[lcls1]) ||
|
||||
(typeId && CATEGORY_BY_TYPE[typeId]) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export interface TourListPlace {
|
||||
source: 'tour';
|
||||
sourceId: string;
|
||||
sourceType: string | null;
|
||||
category: PlaceCategory;
|
||||
sourceCategory: string | null;
|
||||
name: string;
|
||||
addressRoad: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
ldongRegnCd: string | null;
|
||||
ldongSignguCd: string | null;
|
||||
phone: string | null;
|
||||
imageUrl: string | null;
|
||||
imageThumbUrl: string | null;
|
||||
imageLicense: ImageLicense | null;
|
||||
sourceModifiedAt: Date | null;
|
||||
}
|
||||
|
||||
export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id';
|
||||
|
||||
/** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */
|
||||
export function listItemToPlace(
|
||||
item: TourItem,
|
||||
): TourListPlace | { skip: SkipReason } {
|
||||
const sourceId = str(item, 'contentid');
|
||||
const name = htmlToText(item.title);
|
||||
if (!sourceId || !name) return { skip: 'no_id' };
|
||||
const typeId = str(item, 'contenttypeid');
|
||||
if (typeId && SKIP_TYPES.has(typeId)) return { skip: 'not_place' };
|
||||
const lcls1 = str(item, 'lclsSystm1');
|
||||
if (lcls1 === 'EV') return { skip: 'not_place' };
|
||||
const lcls2 = str(item, 'lclsSystm2');
|
||||
const lcls3 = str(item, 'lclsSystm3');
|
||||
const category = toCategory(typeId, lcls1, lcls2, lcls3);
|
||||
if (!category) return { skip: 'no_category' };
|
||||
|
||||
const lat = Number(str(item, 'mapy'));
|
||||
const lng = Number(str(item, 'mapx'));
|
||||
const b = KOREA_BOUNDS;
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng) ||
|
||||
lat < b.latMin ||
|
||||
lat > b.latMax ||
|
||||
lng < b.lngMin ||
|
||||
lng > b.lngMax
|
||||
) {
|
||||
return { skip: 'bad_coords' };
|
||||
}
|
||||
|
||||
const license = str(item, 'cpyrhtDivCd');
|
||||
const address = [str(item, 'addr1'), str(item, 'addr2')]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return {
|
||||
source: 'tour',
|
||||
sourceId,
|
||||
sourceType: typeId,
|
||||
category,
|
||||
sourceCategory: lcls3 ?? lcls2 ?? lcls1,
|
||||
name,
|
||||
addressRoad: address === '' ? null : address,
|
||||
lat,
|
||||
lng,
|
||||
ldongRegnCd: str(item, 'lDongRegnCd'),
|
||||
ldongSignguCd: str(item, 'lDongSignguCd'),
|
||||
phone: htmlToText(item.tel),
|
||||
imageUrl: str(item, 'firstimage'),
|
||||
imageThumbUrl: str(item, 'firstimage2'),
|
||||
imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
|
||||
? (license as ImageLicense)
|
||||
: null,
|
||||
sourceModifiedAt: parseKstTimestamp(str(item, 'modifiedtime')),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TourCommonDetail {
|
||||
overview: string | null;
|
||||
homepage: string | null;
|
||||
}
|
||||
|
||||
/** 공통정보(detailCommon2) → 개요·홈페이지 */
|
||||
export function commonToDetail(item: TourItem): TourCommonDetail {
|
||||
return {
|
||||
overview: htmlToText(item.overview),
|
||||
homepage: extractUrl(item.homepage),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TourIntroDetail {
|
||||
publicHoursText: string | null;
|
||||
publicRestText: string | null;
|
||||
publicHours: ParsedOpeningHours | null;
|
||||
publicExtra: Record<string, string> | null;
|
||||
}
|
||||
|
||||
/** 소개정보(detailIntro2) → 영업시간·쉬는날 원문, 해석 결과, 부가 정보(요금·주차 등) */
|
||||
export function introToDetail(typeId: string, item: TourItem): TourIntroDetail {
|
||||
const fields = INTRO_FIELDS[typeId];
|
||||
if (!fields) {
|
||||
return {
|
||||
publicHoursText: null,
|
||||
publicRestText: null,
|
||||
publicHours: null,
|
||||
publicExtra: null,
|
||||
};
|
||||
}
|
||||
const hours = htmlToText(item[fields.hours]);
|
||||
const rest = htmlToText(item[fields.rest]);
|
||||
const extra: Record<string, string> = {};
|
||||
for (const [name, key] of Object.entries(fields.extra)) {
|
||||
const v = htmlToText(item[key]);
|
||||
if (v) extra[name] = v;
|
||||
}
|
||||
return {
|
||||
publicHoursText: hours,
|
||||
publicRestText: rest,
|
||||
publicHours: hours || rest ? parseOpeningHours(hours, rest) : null,
|
||||
publicExtra: Object.keys(extra).length > 0 ? extra : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TourPetDetail {
|
||||
publicPet: Record<string, string> | null;
|
||||
publicPetPolicy: PublicPetPolicy | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려동물 동반 정보(detailPetTour2) → 원문 + 요약.
|
||||
* 관광공사 정보는 "동반 가능한 곳"만 담는다 — 표시가 애매하면 요약하지 않고(null) 원문만 둔다.
|
||||
*/
|
||||
export function petToDetail(item: TourItem): TourPetDetail {
|
||||
const raw: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
if (key === 'contentid') continue;
|
||||
const v = htmlToText(value);
|
||||
if (v) raw[key] = v;
|
||||
}
|
||||
const type = raw.acmpyTypeCd ?? '';
|
||||
let policy: PublicPetPolicy | null = null;
|
||||
if (/불가/.test(type)) policy = null;
|
||||
else if (/일부/.test(type)) policy = 'partial';
|
||||
else if (/전구역|동반\s*가능/.test(type)) policy = 'allowed';
|
||||
return {
|
||||
publicPet: Object.keys(raw).length > 0 ? raw : null,
|
||||
publicPetPolicy: policy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Place } from '../entities/place.entity';
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
import type { TourItem } from './tour-api.client';
|
||||
import { TourApiFactory } from './tour-api.factory';
|
||||
import { TourPetDetail, petToDetail } from './tour-mapper';
|
||||
|
||||
const PAGE_SIZE = 1_000;
|
||||
const MAX_PAGES = 30; // 2026-10 전국 약 9,700건
|
||||
|
||||
export interface TourPetResult {
|
||||
fetched: number;
|
||||
totalCount: number;
|
||||
updated: number;
|
||||
cleared: number;
|
||||
complete: boolean;
|
||||
calls: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관광공사 반려동물 동반 정보 반영. detailPetTour2 는 contentId 없이 부르면 전국 전체를 준다(약 10회).
|
||||
* 받은 정보를 place(관광공사) 에 붙이고, 목록에서 빠진 장소는 정보를 지운다(끝까지 받았을 때만).
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourPetSync {
|
||||
private readonly logger = new Logger(TourPetSync.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
) {}
|
||||
|
||||
async run(): Promise<TourPetResult> {
|
||||
const budget = new CallBudget(
|
||||
this.apiFactory.dailyLimit(),
|
||||
await this.recorder.usedToday(),
|
||||
);
|
||||
const api = this.apiFactory.create(budget);
|
||||
const run = await this.recorder.start('tour_pet', null);
|
||||
const result: TourPetResult = {
|
||||
fetched: 0,
|
||||
totalCount: 0,
|
||||
updated: 0,
|
||||
cleared: 0,
|
||||
complete: false,
|
||||
calls: {},
|
||||
};
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const byId = new Map<string, TourPetDetail>();
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
let items: TourItem[];
|
||||
try {
|
||||
const res = await api.get('detailPetTour2', {
|
||||
numOfRows: PAGE_SIZE,
|
||||
pageNo: page,
|
||||
});
|
||||
items = res.items;
|
||||
result.totalCount = res.totalCount;
|
||||
} catch (error) {
|
||||
if (error instanceof BudgetExceededError) {
|
||||
errors.push(error.message);
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
result.fetched += items.length;
|
||||
for (const item of items) {
|
||||
const raw = item.contentid;
|
||||
const id =
|
||||
typeof raw === 'string' || typeof raw === 'number'
|
||||
? String(raw)
|
||||
: null;
|
||||
if (id) byId.set(id, petToDetail(item));
|
||||
}
|
||||
if (items.length === 0 || result.fetched >= result.totalCount) {
|
||||
result.complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const places = await this.places.find({
|
||||
where: { source: 'tour' },
|
||||
select: {
|
||||
id: true,
|
||||
sourceId: true,
|
||||
publicPet: true,
|
||||
publicPetPolicy: true,
|
||||
},
|
||||
});
|
||||
for (const place of places) {
|
||||
const next = byId.get(place.sourceId);
|
||||
if (!next && !result.complete) continue; // 덜 받았으면 지우지 않는다
|
||||
const target: TourPetDetail = next ?? {
|
||||
publicPet: null,
|
||||
publicPetPolicy: null,
|
||||
};
|
||||
const same =
|
||||
JSON.stringify(place.publicPet) ===
|
||||
JSON.stringify(target.publicPet) &&
|
||||
place.publicPetPolicy === target.publicPetPolicy;
|
||||
if (same) continue;
|
||||
await this.places.update(place.id, target);
|
||||
if (next) result.updated += 1;
|
||||
else result.cleared += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.unshift(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
} finally {
|
||||
result.calls = budget.spent();
|
||||
await this.recorder.finish(run, {
|
||||
fetched: result.fetched,
|
||||
updated: result.updated + result.cleared,
|
||||
calls: result.calls,
|
||||
errors,
|
||||
});
|
||||
this.logger.log(
|
||||
`반려동물 정보 ${result.fetched}/${result.totalCount}건 받음 → 장소 갱신 ${result.updated} · 정보 삭제 ${result.cleared}` +
|
||||
(result.complete ? '' : ' — 끝까지 받지 못해 삭제는 건너뜀'),
|
||||
);
|
||||
for (const e of errors.slice(0, 5)) this.logger.warn(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ describe('validateEnv', () => {
|
||||
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('관광공사 API 하루 한도가 숫자가 아니면 실패한다', () => {
|
||||
expect(() =>
|
||||
validateEnv({ NODE_ENV: 'development', TOUR_API_DAILY_LIMIT: '천' }),
|
||||
).toThrow(/TOUR_API_DAILY_LIMIT 는 양의 정수/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('운영 환경', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ const POSITIVE_INT_KEYS = [
|
||||
'DB_PORT',
|
||||
'THROTTLE_TTL_MS',
|
||||
'THROTTLE_LIMIT',
|
||||
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
|
||||
'TOUR_API_DAILY_LIMIT',
|
||||
] as const;
|
||||
|
||||
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { extractUrl, htmlToText } from './html-text';
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => {
|
||||
expect(htmlToText('주차 가능<br>소형 20대<br />대형 2대')).toBe(
|
||||
'주차 가능\n소형 20대\n대형 2대',
|
||||
);
|
||||
expect(htmlToText('<strong>무료</strong> 입장')).toBe('무료 입장');
|
||||
});
|
||||
|
||||
it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
|
||||
expect(htmlToText('A & B <안내>')).toBe('A & B <안내>');
|
||||
});
|
||||
|
||||
it('빈 값·공백뿐인 값은 null 이다', () => {
|
||||
expect(htmlToText('')).toBeNull();
|
||||
expect(htmlToText(' <br> ')).toBeNull();
|
||||
expect(htmlToText(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUrl', () => {
|
||||
it('a 태그의 href 를 꺼낸다', () => {
|
||||
expect(
|
||||
extractUrl(
|
||||
'<a href="https://tour.daegu.go.kr" target="_blank" title="새창">tour.daegu.go.kr</a>',
|
||||
),
|
||||
).toBe('https://tour.daegu.go.kr');
|
||||
});
|
||||
|
||||
it('태그 없이 주소만 있으면 그대로, www 로 시작하면 https 를 붙인다', () => {
|
||||
expect(extractUrl('http://example.kr/a')).toBe('http://example.kr/a');
|
||||
expect(extractUrl('www.example.kr')).toBe('https://www.example.kr');
|
||||
});
|
||||
|
||||
it('http(s) 가 아닌 링크는 버린다', () => {
|
||||
expect(extractUrl('<a href="javascript:alert(1)">x</a>')).toBeNull();
|
||||
expect(extractUrl('홈페이지 없음')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 공공데이터 텍스트 정리 유틸 (순수 함수).
|
||||
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
|
||||
*/
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
|
||||
* `<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(/<\/p>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim();
|
||||
return text === '' ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 홈페이지 값에서 URL 만 꺼낸다. 관광공사는 `<a href="…" …>…</a>` 를 통째로 준다.
|
||||
* http(s) 가 아닌 값은 버린다(javascript: 등). `www.` 로 시작하면 https 를 붙인다.
|
||||
*/
|
||||
export function extractUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const href = /href\s*=\s*["']([^"']+)["']/i.exec(value)?.[1];
|
||||
const raw = (
|
||||
href ?? /(https?:\/\/[^\s"'<>]+|www\.[^\s"'<>]+)/i.exec(value)?.[1]
|
||||
)?.trim();
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (/^www\./i.test(raw)) return `https://${raw}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { kstStartOfDay, parseKstTimestamp } from './kst';
|
||||
|
||||
describe('kstStartOfDay', () => {
|
||||
it('한국 날짜의 0시를 돌려준다', () => {
|
||||
// 2026-11-02 14:00 KST = 05:00 UTC → 그날 0시 KST = 11-01 15:00 UTC
|
||||
expect(kstStartOfDay(new Date('2026-11-02T05:00:00Z')).toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('UTC 로는 전날이어도 한국 날짜가 바뀌었으면 새 날로 본다', () => {
|
||||
// 2026-11-02 00:30 KST = 11-01 15:30 UTC
|
||||
expect(kstStartOfDay(new Date('2026-11-01T15:30:00Z')).toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseKstTimestamp', () => {
|
||||
it('14자리 한국 시간을 해석한다', () => {
|
||||
expect(parseKstTimestamp('20261102143000')?.toISOString()).toBe(
|
||||
'2026-11-02T05:30:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('8자리 날짜는 그날 0시(KST)로 본다', () => {
|
||||
expect(parseKstTimestamp('20261102')?.toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('형식이 다르거나 없는 날짜면 null', () => {
|
||||
expect(parseKstTimestamp('2026-11-02')).toBeNull();
|
||||
expect(parseKstTimestamp('20260230')).toBeNull();
|
||||
expect(parseKstTimestamp('')).toBeNull();
|
||||
expect(parseKstTimestamp(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 한국 시간(KST, UTC+9) 유틸 (순수 함수). 한국은 서머타임이 없어 고정 오프셋으로 계산한다.
|
||||
*/
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/** 주어진 시각이 속한 한국 날짜의 0시(KST)를 Date 로 돌려준다 */
|
||||
export function kstStartOfDay(now: Date): Date {
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const midnightUtc = Date.UTC(
|
||||
kst.getUTCFullYear(),
|
||||
kst.getUTCMonth(),
|
||||
kst.getUTCDate(),
|
||||
);
|
||||
return new Date(midnightUtc - KST_OFFSET_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYYMMDD' 또는 'YYYYMMDDhhmmss'(한국 시간) 문자열을 Date 로 바꾼다.
|
||||
* 관광공사 modifiedtime·createdtime 형식이다. 형식이 다르거나 없는 날짜면 null.
|
||||
*/
|
||||
export function parseKstTimestamp(value: unknown): Date | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?$/.exec(
|
||||
String(value).trim(),
|
||||
);
|
||||
if (!m) return null;
|
||||
const [y, mo, d, h, mi, s] = m.slice(1).map((v) => Number(v ?? 0));
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Date.UTC(y, mo - 1, d, h, mi, s) - KST_OFFSET_MS);
|
||||
// 2월 30일처럼 넘어가는 날짜는 거른다
|
||||
const back = new Date(date.getTime() + KST_OFFSET_MS);
|
||||
return back.getUTCDate() === d ? date : null;
|
||||
}
|
||||
Reference in New Issue
Block a user