feat(backend): KOPIS 공연 동기화 추가

대구 공연은 관광공사에 거의 없고(90일 1건) KOPIS 에는 153건이 있다.
place:sync -- kopis 로 대구·경북 공연을 매일 받는다.

- 목록 → 새·바뀐(afterdate)·7일 지난 공연만 상세 → 새 공연장만 시설 상세 → 시설 좌표를 공연에 복사
- 시설 좌표가 주소의 시·도 영역 밖이면 비운다(대구 중구 주소에 경주 좌표인 시설이 있었다)
- 포스터 http 주소를 https://kopis.or.kr 로 바꿔 저장, 0.6초 간격 호출(초당 10회 차단)
- 공공데이터 클라이언트에 키 파라미터 이름·호출 간격·경로를 나눠 받게 함
- XML 응답 해석에 fast-xml-parser 추가. 컨테이너 npm 11.20 이 만든 잠금 파일은
  선택 peer·@emnapi 항목을 지워 CI(npm 10)·호스트의 npm ci 가 실패해서, 새 패키지 항목만 기존 잠금에 더했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 22:02:31 +09:00
parent d3d66444bc
commit 45db80d683
16 changed files with 1753 additions and 10 deletions
@@ -0,0 +1,135 @@
import { CallBudget } from './call-budget';
import { KopisApiClient, parseKopisBody } from './kopis-api.client';
import { FetchLike, PublicDataApiError } from './public-data.client';
const KEY = 'kopis+key/123';
const LIST_XML = `<?xml version="1.0" encoding="UTF-8"?>
<dbs>
<db><mt20id>PF100001</mt20id><prfnm>대구시립교향악단 정기연주회 &amp; 협연</prfnm><prfpdfrom>2026.12.04</prfpdfrom></db>
<db><mt20id>PF100002</mt20id><prfnm>장화신은 고양이</prfnm></db>
</dbs>`;
const DETAIL_XML = `<dbs><db>
<mt20id>PF100001</mt20id><mt10id>FC001193</mt10id><telno>053-250-1400</telno>
<styurls><styurl>https://kopis.or.kr/a.jpg</styurl></styurls>
<relates><relate><relatenm>티켓링크</relatenm><relateurl>https://www.ticketlink.co.kr/1</relateurl></relate></relates>
</db></dbs>`;
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 KopisApiClient({
serviceKey: KEY,
budget,
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls, budget };
}
describe('parseKopisBody', () => {
it('<db> 여러 건을 항목 배열로 바꾸고 엔티티를 되돌린다', () => {
const items = parseKopisBody(LIST_XML, 'pblprfr');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
mt20id: 'PF100001',
prfnm: '대구시립교향악단 정기연주회 & 협연',
prfpdfrom: '2026.12.04',
});
});
it('1건이어도 배열이고, 숫자처럼 보이는 값도 문자열로 둔다', () => {
const [db] = parseKopisBody(DETAIL_XML, 'pblprfr-detail');
expect(db.telno).toBe('053-250-1400');
expect(db.styurls).toEqual({ styurl: ['https://kopis.or.kr/a.jpg'] });
expect(db.relates).toEqual({
relate: [
{ relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' },
],
});
});
it('0건(<dbs/>)이면 빈 배열', () => {
expect(parseKopisBody('<dbs/>', 'pblprfr')).toEqual([]);
expect(parseKopisBody('<dbs></dbs>', 'pblprfr')).toEqual([]);
});
it('returncode·errmsg 가 오면 사유를 담은 오류로 본다', () => {
const xml =
'<dbs><db><returncode>02</returncode><errmsg>SERVICE KEY IS NOT REGISTERED ERROR.</errmsg></db></dbs>';
expect(() => parseKopisBody(xml, 'pblprfr')).toThrow(
/pblprfr: 결과 코드 02 SERVICE KEY IS NOT REGISTERED ERROR\./,
);
});
it('XML 이 아니거나 구조가 다르면 오류', () => {
expect(() =>
parseKopisBody('<html><body>점검 중</body></html>', 'x'),
).toThrow(PublicDataApiError);
expect(() => parseKopisBody('', 'x')).toThrow(PublicDataApiError);
});
});
describe('KopisApiClient', () => {
it('키는 service 파라미터로 싣고 목록 조건을 그대로 넘긴다', async () => {
const { api, urls, budget } = client([LIST_XML]);
const items = await api.listPerformances({
stdate: '20261103',
eddate: '20270201',
signgucode: '27',
cpage: 1,
rows: 100,
});
expect(items).toHaveLength(2);
const url = new URL(urls[0]);
expect(url.origin + url.pathname).toBe(
'https://kopis.or.kr/openApi/restful/pblprfr',
);
expect(url.searchParams.get('service')).toBe(KEY);
expect(url.searchParams.has('serviceKey')).toBe(false);
expect(url.searchParams.get('signgucode')).toBe('27');
expect(url.searchParams.has('afterdate')).toBe(false);
expect(budget.spent()).toEqual({ pblprfr: 1 });
});
it('상세는 경로에 id 를 넣고 한도는 오퍼레이션 이름으로 센다', async () => {
const { api, urls, budget } = client([DETAIL_XML, '<dbs/>']);
await expect(api.getPerformance('PF100001')).resolves.toMatchObject({
mt10id: 'FC001193',
});
await expect(api.getVenue('FC001193')).resolves.toBeNull();
expect(new URL(urls[0]).pathname).toMatch(/\/pblprfr\/PF100001$/);
expect(new URL(urls[1]).pathname).toMatch(/\/prfplc\/FC001193$/);
expect(budget.spent()).toEqual({
'pblprfr-detail': 1,
'prfplc-detail': 1,
});
});
it('영문·숫자가 아닌 id 는 부르지 않는다(경로 조작 방지)', async () => {
const { api, urls } = client([]);
await expect(api.getPerformance('../prfplc/FC1')).rejects.toThrow(
PublicDataApiError,
);
expect(urls).toHaveLength(0);
});
it('오류 메시지에 키가 섞여도 가린다', async () => {
const { api } = client([
`<dbs><db><returncode>02</returncode><errmsg>bad key ${KEY}</errmsg></db></dbs>`,
]);
const error = await api.getVenue('FC1').catch((e: Error) => e);
expect(error).toBeInstanceOf(PublicDataApiError);
expect((error as Error).message).not.toContain(KEY);
});
});
@@ -0,0 +1,135 @@
import { XMLParser } from 'fast-xml-parser';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
asRecord,
toItems,
} from './public-data.client';
/**
* KOPIS 공연예술통합전산망 Open API (XML).
* 2026-11-03 진단: https 동작, 90일 기간을 한 번에 조회 가능, 초당 10회에서 차단되므로 0.6초 간격으로 부른다.
* 출처 표기 의무: "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)".
*/
export const KOPIS_API_BASE_URL = 'https://kopis.or.kr/openApi/restful';
const KOPIS_CALL_INTERVAL_MS = 600;
/** 호출 종류 — 한도·실행 기록의 오퍼레이션 이름 */
export const KOPIS_OPERATIONS = {
list: 'pblprfr',
detail: 'pblprfr-detail',
venue: 'prfplc-detail',
} as const;
export type KopisItem = PublicDataItem;
/** 같은 이름이 여러 번 나와도 하나만 나와도 배열로 받는 태그 */
const ARRAY_TAGS = new Set(['db', 'styurl', 'relate']);
const parser = new XMLParser({
ignoreAttributes: true,
// 숫자처럼 보이는 값(공연 id·전화번호·좌표)도 문자열 그대로 둔다
parseTagValue: false,
trimValues: true,
isArray: (tagName) => ARRAY_TAGS.has(tagName),
});
/**
* KOPIS 응답 → `<db>` 항목들. 목록·상세·공연시설 모두 `<dbs><db>…</db></dbs>` 구조다.
* 키 오류 등은 `<db><returncode>…</returncode><errmsg>…</errmsg></db>` 로 온다. 0건이면 `<dbs/>`.
*/
export function parseKopisBody(body: string, operation: string): KopisItem[] {
let doc: unknown;
try {
doc = parser.parse(body);
} catch {
throw new PublicDataApiError(`${operation}: XML 이 아닌 응답`);
}
const root = asRecord(doc);
if (!root || !('dbs' in root)) {
throw new PublicDataApiError(`${operation}: 알 수 없는 응답 형식`);
}
const items = toItems(asRecord(root.dbs)?.db);
const first = items[0];
if (first && ('returncode' in first || 'errmsg' in first)) {
const code = typeof first.returncode === 'string' ? first.returncode : '';
const msg = typeof first.errmsg === 'string' ? first.errmsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
return items;
}
/** 동기화가 쓰는 KOPIS 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KopisApi {
/** 공연목록 한 쪽 — 기간이 겹치는 공연(stdate·eddate 'YYYYMMDD') */
listPerformances(params: {
stdate: string;
eddate: string;
signgucode: string;
cpage: number;
rows: number;
afterdate?: string;
}): Promise<KopisItem[]>;
/** 공연상세(mt20id) — 없으면 null */
getPerformance(mt20id: string): Promise<KopisItem | null>;
/** 공연시설상세(mt10id) — 없으면 null */
getVenue(mt10id: string): Promise<KopisItem | null>;
}
/** 경로에 넣는 id — 영문·숫자만 허용한다(경로 조작 방지) */
function safeId(id: string, operation: string): string {
if (!/^[A-Za-z0-9]+$/.test(id)) {
throw new PublicDataApiError(`${operation}: 형식이 잘못된 id`);
}
return id;
}
export class KopisApiClient
extends PublicDataClient<KopisItem[]>
implements KopisApi
{
constructor(options: PublicDataClientOptions) {
super(
options,
KOPIS_API_BASE_URL,
{},
{ keyParam: 'service', intervalMs: KOPIS_CALL_INTERVAL_MS },
);
}
protected parse(body: string, operation: string): KopisItem[] {
return parseKopisBody(body, operation);
}
listPerformances(params: {
stdate: string;
eddate: string;
signgucode: string;
cpage: number;
rows: number;
afterdate?: string;
}): Promise<KopisItem[]> {
const { afterdate, ...rest } = params;
return this.get(KOPIS_OPERATIONS.list, {
...rest,
...(afterdate ? { afterdate } : {}),
});
}
async getPerformance(mt20id: string): Promise<KopisItem | null> {
const op = KOPIS_OPERATIONS.detail;
const items = await this.get(op, {}, `pblprfr/${safeId(mt20id, op)}`);
return items[0] ?? null;
}
async getVenue(mt10id: string): Promise<KopisItem | null> {
const op = KOPIS_OPERATIONS.venue;
const items = await this.get(op, {}, `prfplc/${safeId(mt10id, op)}`);
return items[0] ?? null;
}
}
@@ -0,0 +1,207 @@
import {
kopisDate,
kopisDetail,
kopisImageUrl,
kopisListItemToEvent,
kopisTimestamp,
kopisVenue,
} from './kopis-mapper';
describe('kopisDate', () => {
it("'YYYY.MM.DD' 를 ISO 날짜로 바꾼다", () => {
expect(kopisDate('2026.12.04')).toBe('2026-12-04');
});
it('형식이 다르거나 없는 날짜면 null', () => {
expect(kopisDate('20261204')).toBeNull();
expect(kopisDate('2026.02.30')).toBeNull();
expect(kopisDate(null)).toBeNull();
});
});
describe('kopisTimestamp', () => {
it('한국 시간 수정일(마이크로초 포함)을 UTC 로 바꾼다', () => {
expect(kopisTimestamp('2026-11-03 15:00:03.094823')?.toISOString()).toBe(
'2026-11-03T06:00:03.000Z',
);
});
it('형식이 다르면 null', () => {
expect(kopisTimestamp('2026.11.03')).toBeNull();
});
});
describe('kopisImageUrl', () => {
it('KOPIS http 주소는 https://kopis.or.kr 로 바꾼다', () => {
expect(
kopisImageUrl('http://www.kopis.or.kr/upload/pfmPoster/PF_1.gif'),
).toBe('https://kopis.or.kr/upload/pfmPoster/PF_1.gif');
expect(kopisImageUrl('/upload/pfmPoster/PF_2.jpg')).toBe(
'https://kopis.or.kr/upload/pfmPoster/PF_2.jpg',
);
});
it('다른 곳의 https 는 그대로, http 는 버린다', () => {
expect(kopisImageUrl('https://cdn.example.org/a.jpg')).toBe(
'https://cdn.example.org/a.jpg',
);
expect(kopisImageUrl('http://cdn.example.org/a.jpg')).toBeNull();
expect(kopisImageUrl(null)).toBeNull();
});
});
describe('kopisListItemToEvent', () => {
const item = {
mt20id: 'PF281234',
prfnm: '제531회 대구시립교향악단 정기연주회',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.04',
fcltynm: '대구콘서트하우스',
poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
area: '대구광역시',
genrenm: '서양음악(클래식)',
prfstate: '공연예정',
};
it('목록 항목을 기본 컬럼으로 바꾼다', () => {
expect(kopisListItemToEvent(item)).toEqual({
sourceId: 'PF281234',
name: '제531회 대구시립교향악단 정기연주회',
startDate: '2026-12-04',
endDate: '2026-12-04',
genre: '서양음악(클래식)',
eventPlace: '대구콘서트하우스',
area: '대구광역시',
imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
imageThumbUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
});
});
it('id·이름이 없거나 id 형식이 이상하면 건너뛴다', () => {
expect(kopisListItemToEvent({ ...item, mt20id: '' })).toEqual({
skip: 'no_id',
});
expect(kopisListItemToEvent({ ...item, mt20id: 'PF/1' })).toEqual({
skip: 'no_id',
});
});
it('기간이 틀렸거나 끝난 공연은 건너뛴다', () => {
expect(kopisListItemToEvent({ ...item, prfpdto: '2026.12.01' })).toEqual({
skip: 'bad_dates',
});
expect(kopisListItemToEvent({ ...item, prfstate: '공연완료' })).toEqual({
skip: 'ended',
});
});
});
describe('kopisDetail', () => {
const db = {
mt20id: 'PF281234',
mt10id: 'FC001193',
prfnm: '제531회 대구시립교향악단 정기연주회',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.04',
fcltynm: '대구콘서트하우스 (그랜드홀 (대공연장))',
prfcast: '백진현, 김민지',
prfruntime: '1시간 40분',
prfage: '만 7세 이상',
pcseguidance: 'R석 30,000원, S석 16,000원',
dtguidance: '금요일(19:30)',
sty: '<p>송년 음악회</p>',
genrenm: '서양음악(클래식)',
poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
updatedate: '2026-11-03 15:00:03.094823',
relates: {
relate: [
{ relatenm: '전화 예매', relateurl: '053-250-1400' },
{ relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' },
],
},
};
it('요금·공연 시간·예매처·시설 id 를 꺼내고 소개에 출연·관람 정보를 붙인다', () => {
expect(kopisDetail(db)).toEqual({
name: '제531회 대구시립교향악단 정기연주회',
startDate: '2026-12-04',
endDate: '2026-12-04',
genre: '서양음악(클래식)',
eventPlace: '대구콘서트하우스 (그랜드홀 (대공연장))',
feeText: 'R석 30,000원, S석 16,000원',
playtimeText: '금요일(19:30)',
overview:
'송년 음악회\n출연: 백진현, 김민지\n관람 시간: 1시간 40분\n관람 연령: 만 7세 이상',
bookingUrl: 'https://www.ticketlink.co.kr/1',
imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
sourceModifiedAt: new Date('2026-11-03T06:00:03.000Z'),
venueSourceId: 'FC001193',
});
});
it('비어 있는 칸은 null 이고, 기간이 틀리면 기간을 쓰지 않는다', () => {
const d = kopisDetail({
mt20id: 'PF1',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.01',
relates: '',
});
expect(d).toMatchObject({
startDate: null,
endDate: null,
overview: null,
bookingUrl: null,
venueSourceId: null,
});
});
});
describe('kopisVenue', () => {
it('주소의 시·도 안 좌표는 그대로 쓴다', () => {
expect(
kopisVenue(
{
fcltynm: '대구콘서트하우스',
adres: '대구광역시 중구 태평로 141 (태평로2가)',
la: '35.876047100000000',
lo: '128.59384449999993',
telno: '053-250-1400',
relateurl: 'http://www.daeguconcerthouse.or.kr',
},
'대구콘서트하우스 (그랜드홀)',
),
).toEqual({
name: '대구콘서트하우스',
address: '대구광역시 중구 태평로 141 (태평로2가)',
lat: 35.8760471,
lng: 128.59384449999993,
phone: '053-250-1400',
homepage: 'http://www.daeguconcerthouse.or.kr',
coordsRejected: false,
});
});
it('대구 주소에 경주 좌표면 좌표를 비운다(2026-11-03 실제 사례)', () => {
const v = kopisVenue(
{
fcltynm: '프로토타운 북성로 본부',
adres: '대구광역시 중구 북성로 96 (북성로1가)',
la: '35.847189000000000',
lo: '129.21230600000000',
},
'프로토타운',
);
expect(v).toMatchObject({ lat: null, lng: null, coordsRejected: true });
});
it('좌표가 없으면 비우되 거부로 세지 않고, 이름이 없으면 대체 이름을 쓴다', () => {
expect(
kopisVenue({ adres: '대구광역시 남구', la: '', lo: '' }, '꿈꾸는씨어터'),
).toMatchObject({
name: '꿈꾸는씨어터',
lat: null,
lng: null,
coordsRejected: false,
});
});
});
@@ -0,0 +1,190 @@
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { parseKstTimestamp } from '@/shared/utils/kst';
import { toIsoDate } from './festival-mapper';
import type { KopisItem } from './kopis-api.client';
import { asRecord, toItems } from './public-data.client';
/**
* KOPIS 공연목록·공연상세·공연시설상세 → festival_event / event_venue 컬럼 (순수 함수).
* 칸 이름은 2026-11-03 진단(tools/data-check/culture_api_check.py) 기준이다.
*/
const KOPIS_HOST_RE = /^https?:\/\/(www\.)?kopis\.or\.kr\//i;
const KOPIS_HTTPS_ORIGIN = 'https://kopis.or.kr/';
/** KOPIS id(공연 PF…, 시설 FC…) — 상세 주소 경로에 들어가므로 영문·숫자만 받는다 */
const KOPIS_ID_RE = /^[A-Za-z0-9]+$/;
/** 끝난 공연 — 기간이 겹쳐 목록에 와도 넣지 않는다 */
const ENDED_STATE = '공연완료';
export interface KopisListEvent {
sourceId: string;
name: string;
startDate: string;
endDate: string;
genre: string | null;
eventPlace: string | null;
/** 목록 area('대구광역시') — 지역 확인용 */
area: string | null;
imageUrl: string | null;
imageThumbUrl: string | null;
}
export type KopisSkipReason = 'no_id' | 'bad_dates' | 'ended';
function str(item: KopisItem, 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;
}
/** 'YYYY.MM.DD' → 'YYYY-MM-DD'. 형식이 틀리거나 없는 날짜면 null */
export function kopisDate(value: string | null): string | null {
const m = value ? /^(\d{4})\.(\d{2})\.(\d{2})$/.exec(value) : null;
return m ? toIsoDate(`${m[1]}${m[2]}${m[3]}`) : null;
}
/** 상세 updatedate('2026-11-03 15:00:03.094823', 한국 시간) → Date */
export function kopisTimestamp(value: string | null): Date | null {
const m = value
? /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value)
: null;
return m ? parseKstTimestamp(m.slice(1).join('')) : null;
}
/**
* 포스터 주소 — KOPIS 는 http://www.kopis.or.kr/upload/… 로 준다.
* 앱은 http 이미지를 막으므로 https://kopis.or.kr/ 로 바꾼다(www 는 https 에서 이 주소로 넘긴다).
*/
export function kopisImageUrl(value: string | null): string | null {
if (!value) return null;
if (KOPIS_HOST_RE.test(value)) {
return value.replace(KOPIS_HOST_RE, KOPIS_HTTPS_ORIGIN);
}
if (value.startsWith('/upload/'))
return `${KOPIS_HTTPS_ORIGIN}${value.slice(1)}`;
return /^https:\/\//i.test(value) ? value : null;
}
/** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function kopisListItemToEvent(
item: KopisItem,
): KopisListEvent | { skip: KopisSkipReason } {
const sourceId = str(item, 'mt20id');
const name = htmlToText(item.prfnm);
if (!sourceId || !KOPIS_ID_RE.test(sourceId) || !name) {
return { skip: 'no_id' };
}
const startDate = kopisDate(str(item, 'prfpdfrom'));
const endDate = kopisDate(str(item, 'prfpdto'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
if (str(item, 'prfstate') === ENDED_STATE) return { skip: 'ended' };
const poster = kopisImageUrl(str(item, 'poster'));
return {
sourceId,
name,
startDate,
endDate,
genre: str(item, 'genrenm'),
eventPlace: htmlToText(item.fcltynm),
area: str(item, 'area'),
imageUrl: poster,
imageThumbUrl: poster,
};
}
export interface KopisDetail {
name: string | null;
startDate: string | null;
endDate: string | null;
genre: string | null;
eventPlace: string | null;
feeText: string | null;
playtimeText: string | null;
overview: string | null;
bookingUrl: string | null;
imageUrl: string | null;
sourceModifiedAt: Date | null;
/** 공연시설 id(mt10id) — 좌표는 시설 상세에만 있다 */
venueSourceId: string | null;
}
/** '출연: …' 처럼 이름을 붙인 줄. 값이 없으면 null */
function labeled(label: string, value: unknown): string | null {
const text = htmlToText(value);
return text ? `${label}: ${text}` : null;
}
/**
* 공연상세 → 상세 컬럼.
* 소개는 줄거리(sty)에 출연·관람 시간·관람 연령을 줄로 붙인다(따로 둘 컬럼이 없다).
* 예매처(relates)는 여러 곳일 수 있어 첫 번째 http(s) 링크를 쓴다.
*/
export function kopisDetail(db: KopisItem): KopisDetail {
const overview = [
htmlToText(db.sty),
labeled('출연', db.prfcast),
labeled('관람 시간', db.prfruntime),
labeled('관람 연령', db.prfage),
]
.filter((line): line is string => line !== null)
.join('\n');
const relates = toItems(asRecord(db.relates)?.relate);
const bookingUrl =
relates.map((r) => extractUrl(r.relateurl)).find((u) => u !== null) ?? null;
const venueId = str(db, 'mt10id');
const startDate = kopisDate(str(db, 'prfpdfrom'));
const endDate = kopisDate(str(db, 'prfpdto'));
const validPeriod =
startDate !== null && endDate !== null && endDate >= startDate;
return {
name: htmlToText(db.prfnm),
startDate: validPeriod ? startDate : null,
endDate: validPeriod ? endDate : null,
genre: str(db, 'genrenm'),
eventPlace: htmlToText(db.fcltynm),
feeText: htmlToText(db.pcseguidance),
playtimeText: htmlToText(db.dtguidance),
overview: overview === '' ? null : overview,
bookingUrl,
imageUrl: kopisImageUrl(str(db, 'poster')),
sourceModifiedAt: kopisTimestamp(str(db, 'updatedate')),
venueSourceId: venueId && KOPIS_ID_RE.test(venueId) ? venueId : null,
};
}
export interface KopisVenue {
name: string;
address: string | null;
lat: number | null;
lng: number | null;
phone: string | null;
homepage: string | null;
/** 좌표가 있었지만 주소의 시·도와 맞지 않아 비웠다 */
coordsRejected: boolean;
}
/**
* 공연시설상세 → event_venue 컬럼.
* 좌표(la·lo)는 주소의 시·도 영역 안일 때만 쓴다 — 다른 지역 좌표가 섞여 있었다(2026-11-03).
*/
export function kopisVenue(db: KopisItem, fallbackName: string): KopisVenue {
const address = htmlToText(db.adres);
const lat = Number(str(db, 'la'));
const lng = Number(str(db, 'lo'));
const hasCoords = isInKorea(lat, lng);
const sido = sidoCodeFromAddress(address);
const ok = hasCoords && (sido === null || isInSido(sido, lat, lng));
return {
name: htmlToText(db.fcltynm) ?? fallbackName,
address,
lat: ok ? lat : null,
lng: ok ? lng : null,
phone: htmlToText(db.telno),
homepage: extractUrl(db.relateurl),
coordsRejected: hasCoords && !ok,
};
}
@@ -0,0 +1,585 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { sidoCodeFromAddress } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { EventVenue } from '../entities/event-venue.entity';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync';
import { KOPIS_OPERATIONS } from './kopis-api.client';
import type { KopisApi, KopisItem } from './kopis-api.client';
import {
KopisSkipReason,
kopisDetail,
kopisListItemToEvent,
kopisVenue,
} from './kopis-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
const SOURCE = 'kopis';
const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** 목록에 바뀐 표시가 없어도 이 기간이 지나면 상세를 다시 받는다(동기화를 며칠 건너뛴 경우 대비) */
const DETAIL_REFRESH_DAYS = 7;
/** 공연시설 상세(주소·좌표)를 다시 받는 주기 */
const VENUE_REFRESH_DAYS = 30;
const DAY_MS = 86_400_000;
/**
* 목록 upsert 때 덮어쓰는 컬럼. 장소 이름(eventPlace)은 상세가 홀 이름까지 주므로 처음 넣을 때만 쓴다.
* 좌표·주소·전화는 공연시설에서 온다(applyVenueLocations).
*/
const LIST_OVERWRITE_PROPS = [
'kind',
'name',
'startDate',
'endDate',
'genre',
'imageUrl',
'imageThumbUrl',
'ldongRegnCd',
'status',
'lastSeenAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = KopisSkipReason | 'other_region' | 'duplicate';
export interface KopisSyncResult {
regionCode: string;
window: { from: string; to: string };
fetched: number;
inserted: number;
updated: number;
hidden: number;
skipped: Partial<Record<SkipReason, number>>;
/** 어제 이후 KOPIS 에서 등록·수정된 공연 수(afterdate) */
changed: number;
detailed: number;
detailFailed: number;
detailRemaining: number;
venuesSynced: number;
venueFailed: number;
/** 좌표가 주소의 시·도와 맞지 않아 비운 공연시설 */
venueCoordsRejected: string[];
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);
/**
* KOPIS 공연 동기화(매일). 오늘부터 N일 안에 열리는 시·도 공연을 받아 festival_event(source='kopis')에 upsert 한다.
* 1. 목록 — 기간이 겹치는 공연. 목록에 없어진 공연은 숨긴다(원본 장애 의심이면 건너뜀)
* 2. 상세 — 새 공연 · 어제 이후 바뀐 공연(afterdate) · 7일 지난 공연만. 요금·공연 시간·예매처·공연시설 id
* 3. 공연시설 — 새 시설과 30일 지난 시설만. 주소·좌표(좌표는 공연 목록·상세에 없다)
* 4. 공연시설의 좌표·주소·전화를 공연에 복사 — 조회 API 가 공연 좌표로 거리를 계산한다
* 출처 표기는 조회 API 가 붙인다(festival.service PROVIDER_LABEL).
*/
@Injectable()
export class KopisSync {
private readonly logger = new Logger(KopisSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
@InjectRepository(EventVenue)
private readonly venues: Repository<EventVenue>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<KopisSyncResult[]> {
const results: KopisSyncResult[] = [];
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<KopisSyncResult> {
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.kopisDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createKopis(budget);
const run = await this.recorder.start('kopis', regionCode);
const result: KopisSyncResult = {
regionCode,
window,
fetched: 0,
inserted: 0,
updated: 0,
hidden: 0,
skipped: {},
changed: 0,
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
venuesSynced: 0,
venueFailed: 0,
venueCoordsRejected: [],
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
await this.syncList(api, regionCode, window, result);
const changed = result.stoppedByBudget
? new Set<string>()
: await this.changedSinceYesterday(api, regionCode, window, result);
if (!result.stoppedByBudget) {
await this.syncDetails(
api,
budget,
regionCode,
window.from,
changed,
result,
);
}
if (!result.stoppedByBudget) {
await this.syncVenues(api, budget, regionCode, window.from, result);
}
await this.applyVenueLocations(regionCode);
result.detailRemaining = await this.detailPendingQuery(
regionCode,
window.from,
new Set(),
).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,
...result.venueCoordsRejected.map(
(name) => `${name}: 좌표가 주소(시·도)와 맞지 않아 비움`,
),
],
});
this.report(result);
}
return result;
}
/** 1. 목록 → upsert, 목록에서 없어진 공연 숨김 */
private async syncList(
api: KopisApi,
regionCode: string,
window: { from: string; to: string },
result: KopisSyncResult,
): 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 rows = new Map<string, Partial<FestivalEvent>>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: KopisItem[];
try {
items = await api.listPerformances({
stdate: toYmd(window.from),
eddate: toYmd(window.to),
signgucode: regionCode,
cpage: page,
rows: PAGE_SIZE,
});
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
throw error;
}
result.fetched += items.length;
for (const item of items) {
const mapped = kopisListItemToEvent(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 (rows.has(mapped.sourceId)) {
this.skip(result, 'duplicate'); // 쪽 경계에서 같은 공연이 두 번 올 수 있다
continue;
}
rows.set(mapped.sourceId, {
...columns,
source: SOURCE,
kind: 'performance',
ldongRegnCd: regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
if (bySourceId.has(mapped.sourceId)) result.updated += 1;
else result.inserted += 1;
}
if (items.length < PAGE_SIZE) {
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;
}
private skip(result: KopisSyncResult, 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();
}
}
/**
* 어제(한국 날짜) 이후 등록·수정된 공연 id — 목록에는 수정일이 없어 따로 묻는다.
* 실패해도 동기화는 계속한다(7일 주기 재조회가 받쳐 준다).
*/
private async changedSinceYesterday(
api: KopisApi,
regionCode: string,
window: { from: string; to: string },
result: KopisSyncResult,
): Promise<Set<string>> {
const changed = new Set<string>();
const afterdate = toYmd(kstDateString(new Date(), -1));
try {
for (let page = 1; page <= MAX_PAGES; page += 1) {
const items = await api.listPerformances({
stdate: toYmd(window.from),
eddate: toYmd(window.to),
signgucode: regionCode,
cpage: page,
rows: PAGE_SIZE,
afterdate,
});
for (const item of items) {
if (typeof item.mt20id === 'string') changed.add(item.mt20id.trim());
}
if (items.length < PAGE_SIZE) break;
}
} catch (error) {
if (error instanceof BudgetExceededError) result.stoppedByBudget = true;
result.errors.push(`최근 수정 공연 조회 실패: ${errorText(error)}`);
}
result.changed = changed.size;
return changed;
}
/** 상세를 받아야 하는 공연 — 새 공연 · 바뀐 공연 · 오래된 공연 */
private detailPendingQuery(
regionCode: string,
today: string,
changed: Set<string>,
) {
const qb = 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 });
const stale = new Date(Date.now() - DETAIL_REFRESH_DAYS * DAY_MS);
if (changed.size > 0) {
qb.andWhere(
'(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale OR e.source_id IN (:...changed))',
{ stale, changed: [...changed] },
);
} else {
qb.andWhere(
'(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale)',
{
stale,
},
);
}
return qb.orderBy('e.start_date', 'ASC').addOrderBy('e.id', 'ASC');
}
/** 2. 공연상세 → 요금·공연 시간·소개·예매처·공연시설 연결 */
private async syncDetails(
api: KopisApi,
budget: CallBudget,
regionCode: string,
today: string,
changed: Set<string>,
result: KopisSyncResult,
): Promise<void> {
const pending = await this.detailPendingQuery(
regionCode,
today,
changed,
).getMany();
const venueIds = new Map<string, string>();
let consecutiveFailures = 0;
for (const event of pending) {
if (!budget.canSpend(KOPIS_OPERATIONS.detail)) {
result.stoppedByBudget = true;
break;
}
try {
const db = await api.getPerformance(event.sourceId);
if (!db) throw new Error('상세가 비어 있음');
const d = kopisDetail(db);
const venueId = d.venueSourceId
? await this.ensureVenue(
d.venueSourceId,
d.eventPlace ?? '공연장',
venueIds,
)
: null;
await this.events.update(event.id, {
// 상세가 주는 값은 상세가 기준이다. 이름·기간·장르·포스터는 비어 있으면 목록 값을 둔다
...(d.name ? { name: d.name } : {}),
...(d.startDate && d.endDate
? { startDate: d.startDate, endDate: d.endDate }
: {}),
...(d.genre ? { genre: d.genre } : {}),
...(d.eventPlace ? { eventPlace: d.eventPlace } : {}),
...(d.imageUrl
? { imageUrl: d.imageUrl, imageThumbUrl: d.imageUrl }
: {}),
feeText: d.feeText,
playtimeText: d.playtimeText,
overview: d.overview,
bookingUrl: d.bookingUrl,
sourceModifiedAt: d.sourceModifiedAt,
venueId,
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;
}
}
}
}
/**
* 공연시설 행을 찾거나 이름만으로 만든다(주소·좌표는 3단계에서 채운다).
* 이미 있는 시설은 건드리지 않는다.
*/
private async ensureVenue(
mt10id: string,
fallbackName: string,
cache: Map<string, string>,
): Promise<string> {
const cached = cache.get(mt10id);
if (cached) return cached;
await this.venues
.createQueryBuilder()
.insert()
.into(EventVenue)
.values({ source: SOURCE, sourceId: mt10id, name: fallbackName })
.orIgnore()
.execute();
const venue = await this.venues.findOneOrFail({
where: { source: SOURCE, sourceId: mt10id },
select: { id: true },
});
cache.set(mt10id, venue.id);
return venue.id;
}
/** 3. 공연시설 상세 — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 오래된 시설만 */
private async syncVenues(
api: KopisApi,
budget: CallBudget,
regionCode: string,
today: string,
result: KopisSyncResult,
): Promise<void> {
const stale = new Date(Date.now() - VENUE_REFRESH_DAYS * DAY_MS);
const pending = await this.venues
.createQueryBuilder('v')
.select(['v.id', 'v.sourceId', 'v.name'])
.where('v.source = :source', { source: SOURCE })
.andWhere('(v.synced_at IS NULL OR v.synced_at < :stale)', { stale })
.andWhere(
`EXISTS (SELECT 1 FROM festival_event e WHERE e.venue_id = v.id AND e.source = :source
AND e.status = 'active' AND e.end_date >= :today AND e.ldong_regn_cd = :region)`,
{ today, region: regionCode },
)
.orderBy('v.source_id', 'ASC')
.getMany();
let consecutiveFailures = 0;
for (const venue of pending) {
if (!budget.canSpend(KOPIS_OPERATIONS.venue)) {
result.stoppedByBudget = true;
break;
}
try {
const db = await api.getVenue(venue.sourceId);
if (!db) throw new Error('시설 상세가 비어 있음');
const { coordsRejected, ...columns } = kopisVenue(db, venue.name);
await this.venues.update(venue.id, {
...columns,
syncedAt: new Date(),
});
if (coordsRejected) result.venueCoordsRejected.push(columns.name);
result.venuesSynced += 1;
consecutiveFailures = 0;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
result.venueFailed += 1;
consecutiveFailures += 1;
result.errors.push(`시설 ${venue.sourceId}: ${errorText(error)}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`시설 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
/** 4. 공연시설의 좌표·주소·전화를 공연에 복사한다(시설 좌표를 고치면 다음 실행에 공연도 바뀐다) */
private async applyVenueLocations(regionCode: string): Promise<void> {
await this.events.query(
`UPDATE festival_event e
SET lat = v.lat, lng = v.lng, address = v.address, phone = v.phone, updated_at = now()
FROM event_venue v
WHERE e.venue_id = v.id AND e.source = $1 AND e.ldong_regn_cd = $2 AND v.synced_at IS NOT NULL
AND (e.lat IS DISTINCT FROM v.lat OR e.lng IS DISTINCT FROM v.lng
OR e.address IS DISTINCT FROM v.address OR e.phone IS DISTINCT FROM v.phone)`,
[SOURCE, regionCode],
);
}
private report(r: KopisSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`KOPIS 공연 ${r.regionCode} (${r.window.from} ~ ${r.window.to}) ${r.fetched}건 → 새 공연 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
this.logger.log(
`상세 ${r.detailed}건 받음(어제 이후 바뀐 공연 ${r.changed}) · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
` / 공연시설 ${r.venuesSynced}곳 받음 · 실패 ${r.venueFailed}` +
(r.stoppedByBudget
? ' — 오늘 한도에 닿아 멈춤, 내일 다시 실행하면 이어서 받는다'
: ''),
);
for (const name of r.venueCoordsRejected) {
this.logger.warn(
`${name}: 좌표가 주소(시·도)와 맞지 않아 비움 — 근처 조회에서 빠진다`,
);
}
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 { KopisSync } from './kopis.sync';
import { PlaceDedupe } from './place-dedupe';
import { PlaceHoursReparse } from './place-hours-reparse';
import { PlaceSyncStatus } from './place-sync-status';
@@ -37,6 +38,7 @@ import { TourPetSync } from './tour-pet.sync';
TourPetSync,
StoreSync,
FestivalSync,
KopisSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
@@ -44,6 +46,7 @@ import { TourPetSync } from './tour-pet.sync';
exports: [
TourKorSync,
FestivalSync,
KopisSync,
TourPetSync,
StoreSync,
PlaceDedupe,
@@ -1,7 +1,7 @@
import { BudgetExceededError, CallBudget } from './call-budget';
/**
* 공공데이터포털(apis.data.go.kr) 공통 호출 규칙 — 관광공사·상가정보 클라이언트가 함께 쓴다.
* 공공데이터 공통 호출 규칙 — 관광공사·상가정보(apis.data.go.kr)·KOPIS 클라이언트가 함께 쓴다.
* 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), 간격을 두고, 네트워크 오류·5xx 만 다시 시도한다.
* **키는 어떤 오류 메시지에도 남기지 않는다.** 응답 해석은 API 마다 달라 하위 클래스가 맡는다.
*/
@@ -9,7 +9,7 @@ import { BudgetExceededError, CallBudget } from './call-budget';
const REQUEST_TIMEOUT_MS = 20_000;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1_000;
const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
const DEFAULT_CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
/** 게이트웨이가 한도 초과로 거부할 때의 표시(오류 코드 22) */
export const QUOTA_EXCEEDED_RE =
@@ -42,6 +42,14 @@ export interface PublicDataClientOptions {
sleep?: (ms: number) => Promise<void>;
}
/** API 마다 다른 호출 규칙 — 키 파라미터 이름, 호출 간격 */
export interface PublicDataClientSettings {
/** 키를 싣는 쿼리 파라미터(공공데이터포털 serviceKey · KOPIS service) */
keyParam?: string;
/** 연속 호출 최소 간격(ms) */
intervalMs?: number;
}
const defaultSleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -78,32 +86,42 @@ export function parseJsonOrThrow(body: string, operation: string): unknown {
export abstract class PublicDataClient<Page> {
private readonly fetchImpl: FetchLike;
private readonly sleep: (ms: number) => Promise<void>;
private readonly keyParam: string;
private readonly intervalMs: number;
private lastCallAt = 0;
protected constructor(
private readonly options: PublicDataClientOptions,
private readonly baseUrl: string,
private readonly commonParams: Record<string, string>,
settings: PublicDataClientSettings = {},
) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.sleep = options.sleep ?? defaultSleep;
this.keyParam = settings.keyParam ?? 'serviceKey';
this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS;
}
/** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */
protected abstract parse(body: string, operation: string): Page;
/**
* @param operation 한도·오류 메시지에 쓰는 이름
* @param path 주소 경로 — 없으면 operation. KOPIS 상세처럼 경로에 id 가 들어가는 호출에 쓴다
*/
async get(
operation: string,
params: Record<string, string | number>,
path: string = operation,
): Promise<Page> {
const query = new URLSearchParams({
serviceKey: this.options.serviceKey,
[this.keyParam]: this.options.serviceKey,
...this.commonParams,
...Object.fromEntries(
Object.entries(params).map(([k, v]) => [k, String(v)]),
),
});
const url = `${this.options.baseUrl ?? this.baseUrl}/${operation}?${query.toString()}`;
const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
@@ -147,7 +165,7 @@ export abstract class PublicDataClient<Page> {
}
private async pace(): Promise<void> {
const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now();
const wait = this.lastCallAt + this.intervalMs - Date.now();
if (wait > 0) await this.sleep(wait);
this.lastCallAt = Date.now();
}
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { parsePositiveInt } from '@/shared/utils/env';
import { CallBudget } from './call-budget';
import { KopisApi, KopisApiClient } from './kopis-api.client';
import { StoreApi, StoreApiClient } from './store-api.client';
import { TourApi, TourApiClient } from './tour-api.client';
@@ -10,11 +11,16 @@ import { TourApi, TourApiClient } from './tour-api.client';
const DEFAULT_TOUR_DAILY_LIMIT = 1_000;
/** 상가정보 개발 계정 하루 한도(대구 3개 구 적재는 30회 안팎) */
const STORE_DAILY_LIMIT = 10_000;
/**
* KOPIS 오퍼레이션별 하루 상한 — 공식 한도는 밝혀져 있지 않아 보수적으로 둔다.
* 대구·경북 첫 적재가 상세 250회·시설 100회 안팎이다(2026-11-03 진단: 대구 153 · 경북 91 공연).
*/
const KOPIS_DAILY_LIMIT = 1_000;
/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */
const SAFETY_MARGIN = 20;
/**
* 공공데이터포털 클라이언트를 만든다(키 하나로 관광공사·상가정보를 모두 부른다).
* 공공데이터 클라이언트를 만든다 — 공공데이터포털 키 하나로 관광공사·상가정보를, KOPIS 는 KOPIS 키로 부른다.
* 테스트에서는 이 프로바이더를 가짜로 바꿔 끼운다.
*/
@Injectable()
@@ -42,12 +48,33 @@ export class PublicDataApiFactory {
return new StoreApiClient({ serviceKey: this.serviceKey(), budget });
}
kopisDailyLimit(): number {
return KOPIS_DAILY_LIMIT - SAFETY_MARGIN;
}
createKopis(budget: CallBudget): KopisApi {
return new KopisApiClient({
serviceKey: this.requireKey(
'KOPIS_SERVICE_KEY',
'KOPIS 에서 발급받은 서비스키',
),
budget,
});
}
private serviceKey(): string {
const key = this.config.get<string>('PUBLIC_DATA_SERVICE_KEY')?.trim();
return this.requireKey(
'PUBLIC_DATA_SERVICE_KEY',
'공공데이터포털 Decoding 키',
);
}
private requireKey(name: string, description: string): string {
const key = this.config.get<string>(name)?.trim();
if (!key || /change[-_]?me/i.test(key)) {
// 키 값은 어떤 경우에도 출력하지 않는다
throw new Error(
'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요',
`${name} 가 설정되지 않았습니다 — backend/.env.{환경} 에 ${description}를 넣으세요`,
);
}
return key;