From 175e2d454b863875e93962e9e36bf7541087a971 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 23:02:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=ED=95=9C=EA=B5=AD=EB=AC=B8?= =?UTF-8?q?=ED=99=94=EC=A0=95=EB=B3=B4=EC=9B=90=20=EC=A0=84=EC=8B=9C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를 place:sync -- kcisa 로 매일 받는다. - 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다) - 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts) - 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도) - 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다 - htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 &lsquo; 처럼 이중 인코딩되어 온다 Co-Authored-By: Claude Opus 5.5 --- backend/src/commands/place-sync.ts | 7 + backend/src/modules/place/sync/event-name.ts | 16 + .../place/sync/kcisa-api.client.spec.ts | 101 +++++ .../modules/place/sync/kcisa-api.client.ts | 132 ++++++ .../modules/place/sync/kcisa-mapper.spec.ts | 108 +++++ .../src/modules/place/sync/kcisa-mapper.ts | 100 +++++ backend/src/modules/place/sync/kcisa.sync.ts | 408 ++++++++++++++++++ .../modules/place/sync/place-sync.module.ts | 3 + .../modules/place/sync/public-data.factory.ts | 13 +- backend/src/shared/utils/html-text.spec.ts | 15 + backend/src/shared/utils/html-text.ts | 41 +- backend/test/kcisa-sync.e2e-spec.ts | 175 ++++++++ 12 files changed, 1109 insertions(+), 10 deletions(-) create mode 100644 backend/src/modules/place/sync/event-name.ts create mode 100644 backend/src/modules/place/sync/kcisa-api.client.spec.ts create mode 100644 backend/src/modules/place/sync/kcisa-api.client.ts create mode 100644 backend/src/modules/place/sync/kcisa-mapper.spec.ts create mode 100644 backend/src/modules/place/sync/kcisa-mapper.ts create mode 100644 backend/src/modules/place/sync/kcisa.sync.ts create mode 100644 backend/test/kcisa-sync.e2e-spec.ts 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) => + `
00NORMAL SERVICE.
${totalCount}1100${items}
`; + +const ITEM = `314159방짜유기박물관 &lsquo;놋&rsquo; 특별전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 = + '
03NODATA_ERROR
'; + 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('한 번만 되돌린다 — &lt; 는 < 로 남는다', () => { + expect(htmlToText('a &lt; 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 = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", - ''': "'", - ' ': ' ', +/** 이름 엔티티 — 한국문화정보원 제목에 `&lsquo;` 처럼 한 번 더 인코딩된 따옴표·가운뎃점이 온다(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'); + }); +});