diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts
index f7a2a87..a48e491 100644
--- a/backend/src/commands/place-sync.ts
+++ b/backend/src/commands/place-sync.ts
@@ -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 {
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') {
diff --git a/backend/src/modules/place/sync/event-name.ts b/backend/src/modules/place/sync/event-name.ts
new file mode 100644
index 0000000..abb34fa
--- /dev/null
+++ b/backend/src/modules/place/sync/event-name.ts
@@ -0,0 +1,16 @@
+/**
+ * 행사 이름 비교 키 (순수 함수) — 출처가 다른 같은 행사를 찾을 때 쓴다.
+ * 괄호·따옴표·공백·문장부호를 지우고 소문자로 바꾼다. 퍼지 매칭은 하지 않는다(틀린 병합이 더 해롭다).
+ * "2026 수성국제비엔날레 《빛의 정원》" → "2026수성국제비엔날레빛의정원"
+ * "[대구] 굿프렌즈 (앵콜)" → "굿프렌즈"
+ */
+export function eventNameKey(name: string): string {
+ return (
+ name
+ // 대괄호·소괄호 안은 지역·회차 메모라 버린다
+ .replace(/\[[^\]]*\]|\([^)]*\)/g, ' ')
+ .toLowerCase()
+ // 한글·영문·숫자 외는 모두 지운다(따옴표·꺾쇠·가운뎃점·공백 …)
+ .replace(/[^0-9a-z가-힣]/g, '')
+ );
+}
diff --git a/backend/src/modules/place/sync/kcisa-api.client.spec.ts b/backend/src/modules/place/sync/kcisa-api.client.spec.ts
new file mode 100644
index 0000000..6d33910
--- /dev/null
+++ b/backend/src/modules/place/sync/kcisa-api.client.spec.ts
@@ -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) =>
+ `${totalCount}1100${items}`;
+
+const ITEM = `- 314159방짜유기박물관 ‘놋’ 특별전2026052720261122전시128.691435.9897
`;
+
+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: '방짜유기박물관 ‘놋’ 특별전',
+ gpsX: '128.6914',
+ });
+ });
+
+ it('0건이면 빈 배열', () => {
+ expect(parseKcisaPage(page('', 0), 'area2')).toEqual({
+ items: [],
+ totalCount: 0,
+ });
+ });
+
+ it('결과 코드가 00 이 아니면 오류', () => {
+ const xml =
+ '';
+ expect(() => parseKcisaPage(xml, 'detail2')).toThrow(
+ /detail2: 결과 코드 03 NODATA_ERROR/,
+ );
+ });
+
+ it('게이트웨이 한도 초과(코드 22)는 한도 오류, 키 오류는 사유를 담은 오류', () => {
+ const quota =
+ 'LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR22';
+ expect(() => parseKcisaPage(quota, 'area2')).toThrow(BudgetExceededError);
+ const badKey =
+ 'SERVICE_KEY_IS_NOT_REGISTERED_ERROR30';
+ expect(() => parseKcisaPage(badKey, 'area2')).toThrow(
+ /SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
+ );
+ });
+
+ it('구조가 다르면 오류', () => {
+ expect(() => parseKcisaPage('', '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 });
+ });
+});
diff --git a/backend/src/modules/place/sync/kcisa-api.client.ts b/backend/src/modules/place/sync/kcisa-api.client.ts
new file mode 100644
index 0000000..d921828
--- /dev/null
+++ b/backend/src/modules/place/sync/kcisa-api.client.ts
@@ -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',
+});
+
+/**
+ * 응답 → 목록. `00…- …` 구조다.
+ * 키 오류·한도 초과는 게이트웨이가 `` 로 답한다(한도 초과는 코드 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;
+ /** 상세(seq) — 없으면 null */
+ getDetail(seq: string): Promise;
+}
+
+export class KcisaApiClient
+ extends PublicDataClient
+ 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 {
+ 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 {
+ const page = await this.get(KCISA_OPERATIONS.detail, { seq });
+ return page.items[0] ?? null;
+ }
+}
diff --git a/backend/src/modules/place/sync/kcisa-mapper.spec.ts b/backend/src/modules/place/sync/kcisa-mapper.spec.ts
new file mode 100644
index 0000000..84962ab
--- /dev/null
+++ b/backend/src/modules/place/sync/kcisa-mapper.spec.ts
@@ -0,0 +1,108 @@
+import { eventNameKey } from './event-name';
+import { kcisaCoords, kcisaDetail, kcisaItemToEvent } from './kcisa-mapper';
+
+describe('kcisaItemToEvent', () => {
+ const item = {
+ seq: '314159',
+ title: '2026년 방짜유기박물관 ‘놋’ 특별전',
+ 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: '
놋그릇 특별전
관람 시간 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('');
+ });
+});
diff --git a/backend/src/modules/place/sync/kcisa-mapper.ts b/backend/src/modules/place/sync/kcisa-mapper.ts
new file mode 100644
index 0000000..dddee12
--- /dev/null
+++ b/backend/src/modules/place/sync/kcisa-mapper.ts
@@ -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),
+ };
+}
diff --git a/backend/src/modules/place/sync/kcisa.sync.ts b/backend/src/modules/place/sync/kcisa.sync.ts
new file mode 100644
index 0000000..72c5857
--- /dev/null
+++ b/backend/src/modules/place/sync/kcisa.sync.ts
@@ -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 = {
+ '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>;
+ detailed: number;
+ detailFailed: number;
+ detailRemaining: number;
+ stoppedByBudget: boolean;
+ calls: Record;
+ 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,
+ private readonly recorder: SyncRunRecorder,
+ private readonly apiFactory: PublicDataApiFactory,
+ ) {}
+
+ /** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
+ async runRegions(options: {
+ regionCodes: string[];
+ days?: number;
+ }): Promise {
+ 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 {
+ 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 {
+ 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>();
+ 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>();
+ 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[]): Promise {
+ 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 {
+ 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);
+ }
+}
diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts
index 794e33c..81c1759 100644
--- a/backend/src/modules/place/sync/place-sync.module.ts
+++ b/backend/src/modules/place/sync/place-sync.module.ts
@@ -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,
diff --git a/backend/src/modules/place/sync/public-data.factory.ts b/backend/src/modules/place/sync/public-data.factory.ts
index d02845a..1f742ae 100644
--- a/backend/src/modules/place/sync/public-data.factory.ts
+++ b/backend/src/modules/place/sync/public-data.factory.ts
@@ -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;
}
diff --git a/backend/src/shared/utils/html-text.spec.ts b/backend/src/shared/utils/html-text.spec.ts
index abfc64a..e566b2e 100644
--- a/backend/src/shared/utils/html-text.spec.ts
+++ b/backend/src/shared/utils/html-text.spec.ts
@@ -18,6 +18,21 @@ describe('htmlToText', () => {
expect(htmlToText('A & B <안내>')).toBe('A & B <안내>');
});
+ it('따옴표·가운뎃점 같은 이름 엔티티와 번호 엔티티도 되돌린다', () => {
+ expect(
+ htmlToText('방짜유기박물관 ‘놋’ 특별전 · 체험'),
+ ).toBe('방짜유기박물관 ‘놋’ 특별전 · 체험');
+ expect(htmlToText('“빛”')).toBe('“빛”');
+ });
+
+ it('한 번만 되돌린다 — < 는 < 로 남는다', () => {
+ expect(htmlToText('a < b')).toBe('a < b');
+ });
+
+ it('모르는 엔티티·잘못된 번호는 그대로 둔다', () => {
+ expect(htmlToText('&unknown; ')).toBe('&unknown; ');
+ });
+
it('빈 값·공백뿐인 값은 null 이다', () => {
expect(htmlToText('')).toBeNull();
expect(htmlToText('
')).toBeNull();
diff --git a/backend/src/shared/utils/html-text.ts b/backend/src/shared/utils/html-text.ts
index 2eb80b0..d8c154a 100644
--- a/backend/src/shared/utils/html-text.ts
+++ b/backend/src/shared/utils/html-text.ts
@@ -3,15 +3,38 @@
* 관광공사 응답에는 `
`, `` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
*/
-const ENTITIES: Record = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- ''': "'",
- ''': "'",
- ' ': ' ',
+/** 이름 엔티티 — 한국문화정보원 제목에 `‘` 처럼 한 번 더 인코딩된 따옴표·가운뎃점이 온다(2026-11-03) */
+const NAMED_ENTITIES: Record = {
+ 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')
diff --git a/backend/test/kcisa-sync.e2e-spec.ts b/backend/test/kcisa-sync.e2e-spec.ts
new file mode 100644
index 0000000..e2ee8e0
--- /dev/null
+++ b/backend/test/kcisa-sync.e2e-spec.ts
@@ -0,0 +1,175 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { DataSource } from 'typeorm';
+
+import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module';
+import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
+import { CallBudget } from '@/modules/place/sync/call-budget';
+import type {
+ KcisaApi,
+ KcisaItem,
+} from '@/modules/place/sync/kcisa-api.client';
+import { KcisaSync } from '@/modules/place/sync/kcisa.sync';
+import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory';
+import { kstDateString } from '@/shared/utils/kst';
+
+/**
+ * 한국문화정보원 전시 동기화 — 가짜 응답으로 전시만 받기·다른 출처 중복 건너뛰기·상세·숨김·원본 장애 판단을 실제 DB 로 검증한다.
+ * 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 '99000…' 일련번호만 쓴다.
+ */
+const REGION = '99';
+const ymd = (addDays: number) =>
+ kstDateString(new Date(), addDays).replace(/-/g, '');
+
+const item = (seq: string, extra: Partial = {}): KcisaItem => ({
+ seq,
+ title: `시험 전시 ${seq}`,
+ startDate: ymd(0),
+ endDate: ymd(30),
+ realmName: '전시',
+ place: '시험미술관',
+ gpsX: '130.6',
+ gpsY: '36.6',
+ thumbnail: 'https://www.culture.go.kr/upload/thumb.jpg',
+ ...extra,
+});
+
+const DETAILS: Record = {
+ '99000001': {
+ price: '무료',
+ contents1: '시험 전시입니다
',
+ phone: '053-000-0000',
+ placeAddr: '시험시 시험구 1',
+ url: 'https://museum.test/1',
+ },
+ '99000002': { price: '5,000원' },
+};
+
+/** 실행마다 바꿔 끼우는 가짜 목록 */
+let fakeList: KcisaItem[] = [];
+
+function fakeApi(budget: CallBudget): KcisaApi {
+ return {
+ listByArea: () => {
+ budget.take('area2');
+ return Promise.resolve({ items: fakeList, totalCount: fakeList.length });
+ },
+ getDetail: (seq) => {
+ budget.take('detail2');
+ return Promise.resolve(DETAILS[seq] ?? null);
+ },
+ };
+}
+
+describe('한국문화정보원 전시 동기화 (e2e)', () => {
+ let moduleRef: TestingModule;
+ let ds: DataSource;
+ let sync: KcisaSync;
+
+ const cleanup = async () => {
+ await ds.query(
+ `DELETE FROM festival_event WHERE (source = 'kcisa' AND source_id LIKE '99%') OR source_id = 'e2e-kc-tour'`,
+ );
+ await ds.query(
+ `DELETE FROM place_sync_run WHERE job = 'kcisa' AND scope = $1`,
+ [REGION],
+ );
+ };
+ const event = (seq: string) =>
+ ds.getRepository(FestivalEvent).findOneByOrFail({
+ source: 'kcisa',
+ sourceId: seq,
+ });
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [PlaceSyncCliModule],
+ })
+ .overrideProvider(PublicDataApiFactory)
+ .useValue({
+ kcisaDailyLimit: () => 1_000,
+ createKcisa: (budget: CallBudget) => fakeApi(budget),
+ })
+ .compile();
+ await moduleRef.init();
+ ds = moduleRef.get(DataSource);
+ await ds.runMigrations();
+ await cleanup();
+ sync = moduleRef.get(KcisaSync);
+
+ // 관광공사에 이미 있는 행사 — 같은 이름의 전시는 넣지 않아야 한다
+ const repo = ds.getRepository(FestivalEvent);
+ await repo.save(
+ repo.create({
+ source: 'tour',
+ sourceId: 'e2e-kc-tour',
+ kind: 'event',
+ name: '시험 비엔날레',
+ startDate: kstDateString(new Date()),
+ endDate: kstDateString(new Date(), 20),
+ ldongRegnCd: REGION,
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ await cleanup();
+ await moduleRef.close();
+ });
+
+ it('첫 실행: 전시만 넣고, 다른 출처에 있는 행사는 건너뛰고, 상세를 받는다(이미지는 넣지 않음)', async () => {
+ fakeList = [
+ item('99000001'),
+ item('99000002', { startDate: ymd(5), endDate: ymd(60) }),
+ item('99000003', { realmName: '음악/콘서트' }),
+ item('99000004', { title: '시험 비엔날레' }),
+ ];
+
+ const result = await sync.run({ regionCode: REGION });
+
+ expect(result).toMatchObject({
+ inserted: 2,
+ detailed: 2,
+ detailRemaining: 0,
+ skipped: { not_exhibition: 1, duplicate_other_source: 1 },
+ calls: { area2: 1, detail2: 2 },
+ });
+ expect(await event('99000001')).toMatchObject({
+ kind: 'exhibition',
+ genre: '전시',
+ eventPlace: '시험미술관',
+ lat: 36.6,
+ lng: 130.6,
+ feeText: '무료',
+ overview: '시험 전시입니다',
+ phone: '053-000-0000',
+ address: '시험시 시험구 1',
+ homepage: 'https://museum.test/1',
+ imageUrl: null,
+ imageThumbUrl: null,
+ });
+ });
+
+ it('목록이 통째로 비면 원본 장애로 보고 숨기지 않는다', async () => {
+ fakeList = [];
+
+ const result = await sync.run({ regionCode: REGION });
+
+ expect(result.hidden).toBe(0);
+ expect(result.errors.join(' ')).toContain('원본 장애');
+ expect((await event('99000001')).status).toBe('active');
+ });
+
+ it('목록에서 하나가 없어지면 숨기고, 상세는 7일이 지나기 전에는 다시 받지 않는다', async () => {
+ fakeList = [item('99000002', { startDate: ymd(5), endDate: ymd(60) })];
+
+ const result = await sync.run({ regionCode: REGION });
+
+ expect(result).toMatchObject({
+ hidden: 1,
+ updated: 1,
+ detailed: 0,
+ calls: { area2: 1 },
+ });
+ expect((await event('99000001')).status).toBe('hidden');
+ });
+});