feat(backend): 한국문화정보원 전시 동기화 추가

전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를
place:sync -- kcisa 로 매일 받는다.

- 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다)
- 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts)
- 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도)
- 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다
- htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 ‘ 처럼 이중 인코딩되어 온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 23:02:15 +09:00
parent 94c8693dad
commit 175e2d454b
12 changed files with 1109 additions and 10 deletions
+7
View File
@@ -8,6 +8,7 @@ import {
DEFAULT_EVENT_REGIONS,
FestivalSync,
} from '@/modules/place/sync/festival.sync';
import { KcisaSync } from '@/modules/place/sync/kcisa.sync';
import { KopisSync } from '@/modules/place/sync/kopis.sync';
import { PlaceDedupe } from '@/modules/place/sync/place-dedupe';
import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse';
@@ -44,6 +45,9 @@ const USAGE = `사용법: place:sync <작업> [옵션]
kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kcisa 한국문화정보원 전시 적재(오늘부터 N일 안, 매일). 공연은 KOPIS 와 겹쳐 전시만, 이미지는 받지 않는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
status 지역별 적재 현황과 오늘 쓴 호출 수
@@ -55,6 +59,7 @@ const JOBS = [
'store',
'event',
'kopis',
'kcisa',
'dedupe',
'reparse-hours',
'status',
@@ -140,6 +145,8 @@ async function main(): Promise<void> {
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kopis') {
await app.get(KopisSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kcisa') {
await app.get(KcisaSync).runRegions({ regionCodes: regions, days });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
@@ -0,0 +1,16 @@
/**
* 행사 이름 비교 키 (순수 함수) — 출처가 다른 같은 행사를 찾을 때 쓴다.
* 괄호·따옴표·공백·문장부호를 지우고 소문자로 바꾼다. 퍼지 매칭은 하지 않는다(틀린 병합이 더 해롭다).
* "2026 수성국제비엔날레 《빛의 정원》" → "2026수성국제비엔날레빛의정원"
* "[대구] 굿프렌즈 (앵콜)" → "굿프렌즈"
*/
export function eventNameKey(name: string): string {
return (
name
// 대괄호·소괄호 안은 지역·회차 메모라 버린다
.replace(/\[[^\]]*\]|\([^)]*\)/g, ' ')
.toLowerCase()
// 한글·영문·숫자 외는 모두 지운다(따옴표·꺾쇠·가운뎃점·공백 …)
.replace(/[^0-9a-z가-힣]/g, '')
);
}
@@ -0,0 +1,101 @@
import { BudgetExceededError, CallBudget } from './call-budget';
import { KcisaApiClient, parseKcisaPage } from './kcisa-api.client';
import { FetchLike, PublicDataApiError } from './public-data.client';
const KEY = 'data+go/kr==';
const page = (items: string, totalCount: number) =>
`<?xml version="1.0" encoding="UTF-8"?><response><header><resultCode>00</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header><body><totalCount>${totalCount}</totalCount><PageNo>1</PageNo><numOfrows>100</numOfrows><items>${items}</items></body></response>`;
const ITEM = `<item><seq>314159</seq><title>방짜유기박물관 &amp;lsquo;놋&amp;rsquo; 특별전</title><startDate>20260527</startDate><endDate>20261122</endDate><realmName>전시</realmName><gpsX>128.6914</gpsX><gpsY>35.9897</gpsY></item>`;
function client(responses: string[], budget = new CallBudget(100)) {
const urls: string[] = [];
const fetchImpl: FetchLike = (url) => {
urls.push(url);
const body = responses.shift();
if (body === undefined) throw new Error('예상보다 많이 호출했다');
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(body),
});
};
const api = new KcisaApiClient({
serviceKey: KEY,
budget,
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls, budget };
}
describe('parseKcisaPage', () => {
it('항목을 배열로 바꾸고 XML 엔티티는 한 번만 푼다(HTML 엔티티는 매퍼가 푼다)', () => {
const res = parseKcisaPage(page(ITEM, 1), 'area2');
expect(res.totalCount).toBe(1);
expect(res.items[0]).toMatchObject({
seq: '314159',
title: '방짜유기박물관 &lsquo;놋&rsquo; 특별전',
gpsX: '128.6914',
});
});
it('0건이면 빈 배열', () => {
expect(parseKcisaPage(page('', 0), 'area2')).toEqual({
items: [],
totalCount: 0,
});
});
it('결과 코드가 00 이 아니면 오류', () => {
const xml =
'<response><header><resultCode>03</resultCode><resultMsg>NODATA_ERROR</resultMsg></header></response>';
expect(() => parseKcisaPage(xml, 'detail2')).toThrow(
/detail2: 결과 코드 03 NODATA_ERROR/,
);
});
it('게이트웨이 한도 초과(코드 22)는 한도 오류, 키 오류는 사유를 담은 오류', () => {
const quota =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR</returnAuthMsg><returnReasonCode>22</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseKcisaPage(quota, 'area2')).toThrow(BudgetExceededError);
const badKey =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseKcisaPage(badKey, 'area2')).toThrow(
/SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
);
});
it('구조가 다르면 오류', () => {
expect(() => parseKcisaPage('<html/>', 'area2')).toThrow(
PublicDataApiError,
);
});
});
describe('KcisaApiClient', () => {
it('serviceKey·지역·기간·쪽 번호를 이 API 의 파라미터 이름으로 보낸다', async () => {
const { api, urls, budget } = client([page(ITEM, 1), page(ITEM, 1)]);
await api.listByArea({
sido: '대구',
from: '20261103',
to: '20270201',
pageNo: 2,
rows: 100,
});
await expect(api.getDetail('314159')).resolves.toMatchObject({
seq: '314159',
});
const list = new URL(urls[0]);
expect(list.origin + list.pathname).toBe(
'https://apis.data.go.kr/B553457/cultureinfo/area2',
);
expect(list.searchParams.get('serviceKey')).toBe(KEY);
expect(list.searchParams.get('sido')).toBe('대구');
expect(list.searchParams.get('PageNo')).toBe('2');
expect(list.searchParams.get('numOfrows')).toBe('100');
expect(new URL(urls[1]).searchParams.get('seq')).toBe('314159');
expect(budget.spent()).toEqual({ area2: 1, detail2: 1 });
});
});
@@ -0,0 +1,132 @@
import { XMLParser } from 'fast-xml-parser';
import { BudgetExceededError } from './call-budget';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
QUOTA_EXCEEDED_RE,
asRecord,
toItems,
} from './public-data.client';
/**
* 한국문화정보원 한눈에보는문화정보조회서비스(공공데이터포털 15138937, XML).
* 키는 관광공사·상가정보와 같은 공공데이터포털 키, 개발 계정 하루 10,000건.
* 2026-11-03 진단: 앞으로 90일 대구 42건(전시 13) · 경북 17건(전시 1), 좌표 채움 대구 38/42.
*/
export const KCISA_API_BASE_URL = 'https://apis.data.go.kr/B553457/cultureinfo';
/** 호출 종류 — 한도·실행 기록의 오퍼레이션 이름 */
export const KCISA_OPERATIONS = {
list: 'area2',
detail: 'detail2',
} as const;
export type KcisaItem = PublicDataItem;
export interface KcisaPage {
items: KcisaItem[];
totalCount: number;
}
const parser = new XMLParser({
ignoreAttributes: true,
// 일련번호·전화·좌표도 문자열 그대로 둔다
parseTagValue: false,
trimValues: true,
isArray: (tagName) => tagName === 'item',
});
/**
* 응답 → 목록. `<response><header><resultCode>00</resultCode>…<body><totalCount/><items><item>…` 구조다.
* 키 오류·한도 초과는 게이트웨이가 `<OpenAPI_ServiceResponse>` 로 답한다(한도 초과는 코드 22).
*/
export function parseKcisaPage(body: string, operation: string): KcisaPage {
if (QUOTA_EXCEEDED_RE.test(body)) {
throw new BudgetExceededError(operation, 'server');
}
let doc: unknown;
try {
doc = parser.parse(body);
} catch {
throw new PublicDataApiError(`${operation}: XML 이 아닌 응답`);
}
const root = asRecord(doc);
const gateway = asRecord(
asRecord(root?.OpenAPI_ServiceResponse)?.cmmMsgHeader,
);
if (gateway) {
const reason =
typeof gateway.returnAuthMsg === 'string' ? gateway.returnAuthMsg : '';
throw new PublicDataApiError(`${operation}: ${reason}`.trim());
}
const response = asRecord(root?.response);
if (!response) {
throw new PublicDataApiError(`${operation}: 알 수 없는 응답 형식`);
}
const header = asRecord(response.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code !== '' && code !== '00' && code !== '0000') {
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
const resBody = asRecord(response.body);
const items = toItems(asRecord(resBody?.items)?.item);
const totalCount = Number(resBody?.totalCount ?? items.length);
return {
items,
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
};
}
/** 동기화가 쓰는 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KcisaApi {
/** 지역별 목록 — 기간이 겹치는 행사(from·to 'YYYYMMDD', sido '대구'·'경북') */
listByArea(params: {
sido: string;
from: string;
to: string;
pageNo: number;
rows: number;
}): Promise<KcisaPage>;
/** 상세(seq) — 없으면 null */
getDetail(seq: string): Promise<KcisaItem | null>;
}
export class KcisaApiClient
extends PublicDataClient<KcisaPage>
implements KcisaApi
{
constructor(options: PublicDataClientOptions) {
super(options, KCISA_API_BASE_URL, {});
}
protected parse(body: string, operation: string): KcisaPage {
return parseKcisaPage(body, operation);
}
listByArea(params: {
sido: string;
from: string;
to: string;
pageNo: number;
rows: number;
}): Promise<KcisaPage> {
return this.get(KCISA_OPERATIONS.list, {
sido: params.sido,
from: params.from,
to: params.to,
PageNo: params.pageNo,
numOfrows: params.rows,
});
}
async getDetail(seq: string): Promise<KcisaItem | null> {
const page = await this.get(KCISA_OPERATIONS.detail, { seq });
return page.items[0] ?? null;
}
}
@@ -0,0 +1,108 @@
import { eventNameKey } from './event-name';
import { kcisaCoords, kcisaDetail, kcisaItemToEvent } from './kcisa-mapper';
describe('kcisaItemToEvent', () => {
const item = {
seq: '314159',
title: '2026년 방짜유기박물관 &lsquo;놋&rsquo; 특별전',
startDate: '20260527',
endDate: '20261122',
realmName: '전시',
place: '대구방짜유기박물관',
area: '대구',
sigungu: '동구',
gpsX: '128.6914',
gpsY: '35.9897',
thumbnail: 'https://www.culture.go.kr/upload/a.jpg',
};
it('전시를 기본 컬럼으로 바꾸고 이미지는 넣지 않는다', () => {
expect(kcisaItemToEvent(item)).toEqual({
sourceId: '314159',
name: '2026년 방짜유기박물관 ‘놋’ 특별전',
startDate: '2026-05-27',
endDate: '2026-11-22',
genre: '전시',
eventPlace: '대구방짜유기박물관',
area: '대구',
lat: 35.9897,
lng: 128.6914,
});
});
it('전시가 아니면 건너뛴다(공연은 KOPIS 가 맡는다)', () => {
expect(kcisaItemToEvent({ ...item, realmName: '음악/콘서트' })).toEqual({
skip: 'not_exhibition',
});
});
it('일련번호가 숫자가 아니거나 기간이 틀리면 건너뛴다', () => {
expect(kcisaItemToEvent({ ...item, seq: 'a1' })).toEqual({
skip: 'no_id',
});
expect(kcisaItemToEvent({ ...item, endDate: '20260101' })).toEqual({
skip: 'bad_dates',
});
});
});
describe('kcisaCoords', () => {
it('gpsY 가 위도, gpsX 가 경도다', () => {
expect(kcisaCoords({ gpsX: '128.6914', gpsY: '35.9897' }, '대구')).toEqual({
lat: 35.9897,
lng: 128.6914,
});
});
it('지역 영역 밖·비어 있음·뒤바뀜이면 좌표를 버린다', () => {
const none = { lat: null, lng: null };
expect(kcisaCoords({ gpsX: '129.2123', gpsY: '35.8471' }, '대구')).toEqual(
none,
);
expect(kcisaCoords({ gpsX: '', gpsY: '' }, '대구')).toEqual(none);
expect(kcisaCoords({ gpsX: '35.9897', gpsY: '128.6914' }, null)).toEqual(
none,
);
});
});
describe('kcisaDetail', () => {
it('요금·소개(HTML 정리)·전화·주소·안내 링크를 꺼낸다', () => {
expect(
kcisaDetail({
price: '무료',
contents1: '<p>놋그릇 특별전</p><br>관람 시간 09:00~18:00',
phone: '053-606-6171',
placeAddr: '대구광역시 동구 도장길 29',
url: 'https://artcenter.daegu.go.kr/bangjja',
}),
).toEqual({
feeText: '무료',
overview: '놋그릇 특별전\n\n관람 시간 09:00~18:00',
phone: '053-606-6171',
address: '대구광역시 동구 도장길 29',
homepage: 'https://artcenter.daegu.go.kr/bangjja',
});
});
it('안내 링크가 없으면 장소 링크를 쓰고, 둘 다 없으면 null', () => {
expect(kcisaDetail({ placeUrl: 'www.daeguartmuseum.or.kr' }).homepage).toBe(
'https://www.daeguartmuseum.or.kr',
);
expect(kcisaDetail({}).homepage).toBeNull();
});
});
describe('eventNameKey', () => {
it('괄호 메모·따옴표·공백·문장부호를 지운 비교 키를 만든다', () => {
expect(eventNameKey('2026 수성국제비엔날레 《빛의 정원》')).toBe(
'2026수성국제비엔날레빛의정원',
);
expect(eventNameKey('[대구] 굿프렌즈 (앵콜)')).toBe('굿프렌즈');
expect(eventNameKey('Jazz in Blue')).toBe(eventNameKey('JAZZ IN BLUE'));
});
it('글자가 하나도 남지 않으면 빈 문자열(비교에 쓰지 않는다)', () => {
expect(eventNameKey('[대구]')).toBe('');
});
});
@@ -0,0 +1,100 @@
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { toIsoDate } from './festival-mapper';
import type { KcisaItem } from './kcisa-api.client';
/**
* 한국문화정보원 한눈에보는문화정보 → festival_event 컬럼 (순수 함수).
* 공연은 KOPIS 와 겹치므로 **전시만** 받는다. 칸 이름은 2026-11-03 진단 기준이다.
*
* 이미지(thumbnail·imgUrl)는 저장하지 않는다 — 문화공공데이터광장 저작권정책상 공공누리 1유형이 아니면
* 사전 협의가 필요한데 아직 확인하지 않았다(docs/research/pre-dev-check/culture_event_sources.md 4장 4번).
*/
/** 받을 분야(realmName) */
export const KCISA_EXHIBITION_REALM = '전시';
/** 일련번호 — 숫자만 받는다 */
const SEQ_RE = /^\d+$/;
export interface KcisaListEvent {
sourceId: string;
name: string;
startDate: string;
endDate: string;
genre: string;
eventPlace: string | null;
/** 목록 area('대구') — 지역 확인용 */
area: string | null;
lat: number | null;
lng: number | null;
}
export type KcisaSkipReason = 'no_id' | 'bad_dates' | 'not_exhibition';
function str(item: KcisaItem, 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;
}
/**
* 좌표 — gpsY 가 위도, gpsX 가 경도다(순서 주의). 주소·지역의 시·도 영역 밖이면 버린다.
*/
export function kcisaCoords(
item: KcisaItem,
regionText: string | null,
): { lat: number | null; lng: number | null } {
const lat = Number(str(item, 'gpsY'));
const lng = Number(str(item, 'gpsX'));
const sido = sidoCodeFromAddress(regionText);
const ok = sido ? isInSido(sido, lat, lng) : isInKorea(lat, lng);
return ok ? { lat, lng } : { lat: null, lng: null };
}
/** 목록 항목 1건 → 기본 컬럼. 전시가 아니거나 쓸 수 없으면 사유를 돌려준다 */
export function kcisaItemToEvent(
item: KcisaItem,
): KcisaListEvent | { skip: KcisaSkipReason } {
const sourceId = str(item, 'seq');
const name = htmlToText(item.title);
if (!sourceId || !SEQ_RE.test(sourceId) || !name) return { skip: 'no_id' };
if (str(item, 'realmName') !== KCISA_EXHIBITION_REALM) {
return { skip: 'not_exhibition' };
}
const startDate = toIsoDate(str(item, 'startDate'));
const endDate = toIsoDate(str(item, 'endDate'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
const area = str(item, 'area');
return {
sourceId,
name,
startDate,
endDate,
genre: KCISA_EXHIBITION_REALM,
eventPlace: htmlToText(item.place),
area,
...kcisaCoords(item, area),
};
}
export interface KcisaDetail {
feeText: string | null;
overview: string | null;
phone: string | null;
address: string | null;
homepage: string | null;
}
/** 상세 → 요금·소개·전화·주소·안내 링크 */
export function kcisaDetail(item: KcisaItem): KcisaDetail {
return {
feeText: htmlToText(item.price),
overview: htmlToText(item.contents1),
phone: htmlToText(item.phone),
address: htmlToText(item.placeAddr),
homepage: extractUrl(item.url) ?? extractUrl(item.placeUrl),
};
}
@@ -0,0 +1,408 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { sidoCodeFromAddress } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { eventNameKey } from './event-name';
import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync';
import { KCISA_OPERATIONS } from './kcisa-api.client';
import type { KcisaApi, KcisaPage } from './kcisa-api.client';
import { KcisaSkipReason, kcisaDetail, kcisaItemToEvent } from './kcisa-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
const SOURCE = 'kcisa';
/** 시·도 코드 → 이 API 의 sido 값 */
export const KCISA_SIDO_NAMES: Record<string, string> = {
'27': '대구',
'47': '경북',
};
const PAGE_SIZE = 100;
const MAX_PAGES = 20;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** 원본에 수정일이 없어 상세를 이 주기로 다시 받는다 */
const DETAIL_REFRESH_DAYS = 7;
const DAY_MS = 86_400_000;
/** 목록 upsert 때 덮어쓰는 컬럼(상세 컬럼은 상세를 받을 때만 쓴다) */
const LIST_OVERWRITE_PROPS = [
'kind',
'name',
'startDate',
'endDate',
'genre',
'eventPlace',
'lat',
'lng',
'ldongRegnCd',
'status',
'lastSeenAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = KcisaSkipReason | 'other_region' | 'duplicate_other_source';
export interface KcisaSyncResult {
regionCode: string;
window: { from: string; to: string };
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[];
}
const toYmd = (iso: string) => iso.replace(/-/g, '');
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
/**
* 한국문화정보원 전시 동기화(매일). 오늘부터 N일 안의 시·도 행사 목록에서 **전시만** festival_event(source='kcisa')에 넣는다.
* 1. 목록(area2) — 전시가 아니거나, 관광공사·KOPIS 에 같은 이름·겹치는 기간의 행사가 이미 있으면 건너뛴다.
* 목록에서 없어진 전시는 숨긴다(원본 장애 의심이면 건너뜀)
* 2. 상세(detail2) — 새 전시와 7일 지난 전시만(원본에 수정일이 없다). 요금·소개·전화·주소·안내 링크
* 좌표는 목록에 있다. 이미지는 권리 확인 전이라 저장하지 않는다(kcisa-mapper.ts).
*/
@Injectable()
export class KcisaSync {
private readonly logger = new Logger(KcisaSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<KcisaSyncResult[]> {
const unknown = options.regionCodes.filter((c) => !KCISA_SIDO_NAMES[c]);
if (unknown.length > 0) {
throw new Error(
`한눈에보는문화정보 지역 이름을 모르는 시·도 코드: ${unknown.join(', ')} (kcisa.sync.ts KCISA_SIDO_NAMES 에 추가)`,
);
}
const results: KcisaSyncResult[] = [];
for (const regionCode of options.regionCodes) {
const result = await this.run({ regionCode, days: options.days });
results.push(result);
if (result.stoppedByBudget) break;
}
return results;
}
async run(options: {
regionCode: string;
days?: number;
}): Promise<KcisaSyncResult> {
const { regionCode } = options;
const now = new Date();
const window = {
from: kstDateString(now),
to: kstDateString(now, options.days ?? DEFAULT_EVENT_LOOKAHEAD_DAYS),
};
const budget = new CallBudget(
this.apiFactory.kcisaDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createKcisa(budget);
const run = await this.recorder.start('kcisa', regionCode);
const result: KcisaSyncResult = {
regionCode,
window,
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, window, result);
if (!result.stoppedByBudget) {
await this.syncDetails(api, budget, regionCode, window.from, result);
}
result.detailRemaining = await this.detailPendingQuery(
regionCode,
window.from,
).getCount();
} catch (error) {
result.errors.unshift(errorText(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;
}
/** 1. 목록 → 전시만 upsert, 목록에서 없어진 전시 숨김 */
private async syncList(
api: KcisaApi,
regionCode: string,
window: { from: string; to: string },
result: KcisaSyncResult,
): Promise<void> {
const runStart = new Date();
const existing = await this.events.find({
where: { source: SOURCE },
select: {
id: true,
sourceId: true,
status: true,
ldongRegnCd: true,
startDate: true,
endDate: true,
},
});
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const isDuplicate = await this.otherSourceMatcher(regionCode, window);
const rows = new Map<string, Partial<FestivalEvent>>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let res: KcisaPage;
try {
res = await api.listByArea({
sido: KCISA_SIDO_NAMES[regionCode],
from: toYmd(window.from),
to: toYmd(window.to),
pageNo: page,
rows: PAGE_SIZE,
});
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
throw error;
}
result.totalCount = res.totalCount;
result.fetched += res.items.length;
for (const item of res.items) {
const mapped = kcisaItemToEvent(item);
if ('skip' in mapped) {
this.skip(result, mapped.skip);
continue;
}
const { area, ...columns } = mapped;
const sido = sidoCodeFromAddress(area);
if (sido !== null && sido !== regionCode) {
this.skip(result, 'other_region');
continue;
}
if (isDuplicate(columns.name, columns.startDate, columns.endDate)) {
this.skip(result, 'duplicate_other_source');
continue;
}
rows.set(columns.sourceId, {
...columns,
source: SOURCE,
kind: 'exhibition',
ldongRegnCd: regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
if (bySourceId.has(columns.sourceId)) result.updated += 1;
else result.inserted += 1;
}
if (res.items.length < PAGE_SIZE || result.fetched >= result.totalCount) {
complete = true;
break;
}
}
await this.upsert([...rows.values()]);
if (!complete) return;
// 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜)
const inWindowBefore = existing.filter(
(e) =>
e.status === 'active' &&
e.ldongRegnCd === regionCode &&
e.endDate >= window.from &&
e.startDate <= window.to,
);
const missing = inWindowBefore.filter((e) => !rows.has(e.sourceId)).length;
if (isSuspiciousRemoval(inWindowBefore.length, missing)) {
result.errors.push(
`기간 안 전시 ${inWindowBefore.length}건 중 ${missing}건이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`,
);
return;
}
const hidden = await this.events
.createQueryBuilder()
.update(FestivalEvent)
.set({ status: 'hidden' })
.where('source = :source', { source: SOURCE })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
/**
* 관광공사·KOPIS 에 이미 있는 행사와 같은지 — 이름 비교 키가 같고 기간이 겹치면 같은 행사로 본다.
* (예: 비엔날레가 관광공사 '행사'와 문화정보원 '전시'에 함께 있다)
*/
private async otherSourceMatcher(
regionCode: string,
window: { from: string; to: string },
): Promise<(name: string, start: string, end: string) => boolean> {
const others = await this.events
.createQueryBuilder('e')
.select(['e.name', 'e.startDate', 'e.endDate'])
.where({ source: Not(SOURCE) })
.andWhere('e.status = :active', { active: 'active' })
.andWhere('e.ldong_regn_cd = :region', { region: regionCode })
.andWhere('e.end_date >= :from AND e.start_date <= :to', window)
.getMany();
const byKey = new Map<string, Array<{ start: string; end: string }>>();
for (const o of others) {
const key = eventNameKey(o.name);
if (!key) continue;
byKey.set(key, [
...(byKey.get(key) ?? []),
{ start: o.startDate, end: o.endDate },
]);
}
return (name, start, end) =>
(byKey.get(eventNameKey(name)) ?? []).some(
(o) => o.end >= start && o.start <= end,
);
}
private skip(result: KcisaSyncResult, reason: SkipReason): void {
result.skipped[reason] = (result.skipped[reason] ?? 0) + 1;
}
private async upsert(rows: Partial<FestivalEvent>[]): Promise<void> {
const overwrite = LIST_OVERWRITE_PROPS.map(
(prop) =>
this.events.metadata.findColumnWithPropertyName(prop)!.databaseName,
);
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
await this.events
.createQueryBuilder()
.insert()
.into(FestivalEvent)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
/** 상세를 받아야 하는 전시 — 새 전시 · 7일 지난 전시 */
private detailPendingQuery(regionCode: string, today: string) {
return this.events
.createQueryBuilder('e')
.select(['e.id', 'e.sourceId'])
.where('e.source = :source', { source: SOURCE })
.andWhere('e.ldong_regn_cd = :region', { region: regionCode })
.andWhere('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :today', { today })
.andWhere('(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale)', {
stale: new Date(Date.now() - DETAIL_REFRESH_DAYS * DAY_MS),
})
.orderBy('e.start_date', 'ASC')
.addOrderBy('e.id', 'ASC');
}
/** 2. 상세 → 요금·소개·전화·주소·안내 링크 */
private async syncDetails(
api: KcisaApi,
budget: CallBudget,
regionCode: string,
today: string,
result: KcisaSyncResult,
): Promise<void> {
const pending = await this.detailPendingQuery(regionCode, today).getMany();
let consecutiveFailures = 0;
for (const event of pending) {
if (!budget.canSpend(KCISA_OPERATIONS.detail)) {
result.stoppedByBudget = true;
break;
}
try {
const item = await api.getDetail(event.sourceId);
if (!item) throw new Error('상세가 비어 있음');
await this.events.update(event.id, {
...kcisaDetail(item),
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(`${event.sourceId}: ${errorText(error)}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
private report(r: KcisaSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`문화정보원 전시 ${r.regionCode} (${r.window.from} ~ ${r.window.to}) ${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);
}
}
@@ -7,6 +7,7 @@ import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import { FestivalSync } from './festival.sync';
import { KcisaSync } from './kcisa.sync';
import { KopisSync } from './kopis.sync';
import { PlaceDedupe } from './place-dedupe';
import { PlaceHoursReparse } from './place-hours-reparse';
@@ -39,6 +40,7 @@ import { TourPetSync } from './tour-pet.sync';
StoreSync,
FestivalSync,
KopisSync,
KcisaSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
@@ -47,6 +49,7 @@ import { TourPetSync } from './tour-pet.sync';
TourKorSync,
FestivalSync,
KopisSync,
KcisaSync,
TourPetSync,
StoreSync,
PlaceDedupe,
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { parsePositiveInt } from '@/shared/utils/env';
import { CallBudget } from './call-budget';
import { KcisaApi, KcisaApiClient } from './kcisa-api.client';
import { KopisApi, KopisApiClient } from './kopis-api.client';
import { StoreApi, StoreApiClient } from './store-api.client';
import { TourApi, TourApiClient } from './tour-api.client';
@@ -11,6 +12,8 @@ import { TourApi, TourApiClient } from './tour-api.client';
const DEFAULT_TOUR_DAILY_LIMIT = 1_000;
/** 상가정보 개발 계정 하루 한도(대구 3개 구 적재는 30회 안팎) */
const STORE_DAILY_LIMIT = 10_000;
/** 한국문화정보원 한눈에보는문화정보 개발 계정 하루 한도(대구·경북 매일 실행은 20회 안팎) */
const KCISA_DAILY_LIMIT = 10_000;
/**
* KOPIS 오퍼레이션별 하루 상한 — 공식 한도는 밝혀져 있지 않아 보수적으로 둔다.
* 대구·경북 첫 적재가 상세 250회·시설 100회 안팎이다(2026-11-03 진단: 대구 153 · 경북 91 공연).
@@ -20,7 +23,7 @@ const KOPIS_DAILY_LIMIT = 1_000;
const SAFETY_MARGIN = 20;
/**
* 공공데이터 클라이언트를 만든다 — 공공데이터포털 키 하나로 관광공사·상가정보를, KOPIS 는 KOPIS 키로 부른다.
* 공공데이터 클라이언트를 만든다 — 공공데이터포털 키 하나로 관광공사·상가정보·한국문화정보원을, KOPIS 는 KOPIS 키로 부른다.
* 테스트에서는 이 프로바이더를 가짜로 바꿔 끼운다.
*/
@Injectable()
@@ -48,6 +51,14 @@ export class PublicDataApiFactory {
return new StoreApiClient({ serviceKey: this.serviceKey(), budget });
}
kcisaDailyLimit(): number {
return KCISA_DAILY_LIMIT - SAFETY_MARGIN;
}
createKcisa(budget: CallBudget): KcisaApi {
return new KcisaApiClient({ serviceKey: this.serviceKey(), budget });
}
kopisDailyLimit(): number {
return KOPIS_DAILY_LIMIT - SAFETY_MARGIN;
}
@@ -18,6 +18,21 @@ describe('htmlToText', () => {
expect(htmlToText('A &amp; B &lt;안내&gt;')).toBe('A & B <안내>');
});
it('따옴표·가운뎃점 같은 이름 엔티티와 번호 엔티티도 되돌린다', () => {
expect(
htmlToText('방짜유기박물관 &lsquo;놋&rsquo; 특별전 &middot; 체험'),
).toBe('방짜유기박물관 ‘놋’ 특별전 · 체험');
expect(htmlToText('&#8220;빛&#x201D;')).toBe('“빛”');
});
it('한 번만 되돌린다 — &amp;lt; 는 &lt; 로 남는다', () => {
expect(htmlToText('a &amp;lt; b')).toBe('a &lt; b');
});
it('모르는 엔티티·잘못된 번호는 그대로 둔다', () => {
expect(htmlToText('&unknown; &#99999999;')).toBe('&unknown; &#99999999;');
});
it('빈 값·공백뿐인 값은 null 이다', () => {
expect(htmlToText('')).toBeNull();
expect(htmlToText(' <br> ')).toBeNull();
+32 -9
View File
@@ -3,15 +3,38 @@
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
*/
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&nbsp;': ' ',
/** 이름 엔티티 — 한국문화정보원 제목에 `&amp;lsquo;` 처럼 한 번 더 인코딩된 따옴표·가운뎃점이 온다(2026-11-03) */
const NAMED_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
lsquo: '‘',
rsquo: '’',
ldquo: '“',
rdquo: '”',
middot: '·',
hellip: '…',
ndash: '–',
mdash: '—',
lang: '〈',
rang: '〉',
};
const MAX_CODE_POINT = 0x10ffff;
/** 엔티티 하나를 글자로. 모르는 이름·잘못된 번호는 그대로 둔다 */
function decodeEntity(match: string, body: string): string {
if (body.startsWith('#')) {
const hex = body[1] === 'x' || body[1] === 'X';
const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
return Number.isInteger(code) && code > 0 && code <= MAX_CODE_POINT
? String.fromCodePoint(code)
: match;
}
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
}
/**
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
@@ -23,7 +46,7 @@ export function htmlToText(value: unknown): string | null {
.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(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, decodeEntity)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')