generated from ttipo/project-base
feat(backend): 한국문화정보원 전시 동기화 추가
전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를 place:sync -- kcisa 로 매일 받는다. - 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다) - 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts) - 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도) - 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다 - htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 &lsquo; 처럼 이중 인코딩되어 온다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>방짜유기박물관 &lsquo;놋&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: '방짜유기박물관 ‘놋’ 특별전',
|
||||
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년 방짜유기박물관 ‘놋’ 특별전',
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user