generated from ttipo/project-base
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:
@@ -8,6 +8,7 @@ import {
|
||||
DEFAULT_EVENT_REGIONS,
|
||||
FestivalSync,
|
||||
} from '@/modules/place/sync/festival.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';
|
||||
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
|
||||
@@ -25,7 +26,7 @@ import { PlaceSyncCliModule } from './place-sync.cli.module';
|
||||
|
||||
/**
|
||||
* 장소 동기화 명령 (설계: docs/design/place-db.md §4).
|
||||
* 공공데이터 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
|
||||
* 공공데이터·KOPIS 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
|
||||
*
|
||||
* 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션]
|
||||
* 운영: node dist/commands/place-sync.js <작업> [옵션] (cron)
|
||||
@@ -40,6 +41,9 @@ const USAGE = `사용법: place:sync <작업> [옵션]
|
||||
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
|
||||
--regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북)
|
||||
--days=90 며칠 뒤까지 받을지
|
||||
kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다
|
||||
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
|
||||
--days=90 며칠 뒤까지 받을지
|
||||
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
|
||||
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
|
||||
status 지역별 적재 현황과 오늘 쓴 호출 수
|
||||
@@ -50,6 +54,7 @@ const JOBS = [
|
||||
'tour-pet',
|
||||
'store',
|
||||
'event',
|
||||
'kopis',
|
||||
'dedupe',
|
||||
'reparse-hours',
|
||||
'status',
|
||||
@@ -133,6 +138,8 @@ async function main(): Promise<void> {
|
||||
await app.get(StoreSync).run({ signguCodes: signgu });
|
||||
} else if (job === 'event') {
|
||||
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
|
||||
} else if (job === 'kopis') {
|
||||
await app.get(KopisSync).runRegions({ regionCodes: regions, days });
|
||||
} else if (job === 'dedupe') {
|
||||
await app.get(PlaceDedupe).run();
|
||||
} else if (job === 'reparse-hours') {
|
||||
|
||||
@@ -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>대구시립교향악단 정기연주회 & 협연</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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isInKorea } from './geo';
|
||||
import { isInKorea, isInSido, sidoCodeFromAddress } from './geo';
|
||||
|
||||
describe('isInKorea', () => {
|
||||
it('대구 좌표는 한국 안이다', () => {
|
||||
@@ -14,3 +14,33 @@ describe('isInKorea', () => {
|
||||
expect(isInKorea(Number.NaN, 128.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidoCodeFromAddress', () => {
|
||||
it('주소 앞머리로 대구·경북 코드를 찾는다', () => {
|
||||
expect(sidoCodeFromAddress('대구광역시 중구 태평로 141')).toBe('27');
|
||||
expect(sidoCodeFromAddress('경상북도 경주시 알천북로 1')).toBe('47');
|
||||
expect(sidoCodeFromAddress(' 경북 포항시 북구')).toBe('47');
|
||||
});
|
||||
|
||||
it('모르는 지역이거나 비어 있으면 null', () => {
|
||||
expect(sidoCodeFromAddress('부산광역시 해운대구')).toBeNull();
|
||||
expect(sidoCodeFromAddress(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInSido', () => {
|
||||
it('대구 도심·군위 좌표는 대구 안이다', () => {
|
||||
expect(isInSido('27', 35.8761, 128.5938)).toBe(true);
|
||||
expect(isInSido('27', 36.2428, 128.5728)).toBe(true);
|
||||
});
|
||||
|
||||
it('대구 주소에 경주 좌표(2026-11-03 KOPIS 실제 사례)는 대구 밖이다', () => {
|
||||
expect(isInSido('27', 35.847189, 129.212306)).toBe(false);
|
||||
expect(isInSido('47', 35.847189, 129.212306)).toBe(true);
|
||||
});
|
||||
|
||||
it('영역을 모르는 시·도는 한국 안인지만 본다', () => {
|
||||
expect(isInSido('26', 35.1587, 129.1604)).toBe(true);
|
||||
expect(isInSido('26', 0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,3 +20,44 @@ export function isInKorea(lat: number, lng: number): boolean {
|
||||
lng <= b.lngMax
|
||||
);
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
latMin: number;
|
||||
latMax: number;
|
||||
lngMin: number;
|
||||
lngMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시·도 영역 대략값(법정동 시·도 코드 2자리) — 원본 좌표가 주소와 다른 지역을 가리키는지 본다.
|
||||
* 2026-11-03 KOPIS 공연시설 중 대구 중구 주소인데 경주 좌표(경도 129.21)인 곳이 있었다.
|
||||
* 동기화 범위(대구·경북)만 둔다. 없는 코드는 한국 영역만 확인한다.
|
||||
*/
|
||||
const SIDO_BOUNDS: Record<string, Bounds> = {
|
||||
// 대구 — 2023년 편입한 군위군 포함
|
||||
'27': { latMin: 35.55, latMax: 36.36, lngMin: 128.3, lngMax: 128.9 },
|
||||
// 경북 — 울릉도·독도 포함
|
||||
'47': { latMin: 35.55, latMax: 37.6, lngMin: 127.75, lngMax: 131.95 },
|
||||
};
|
||||
|
||||
/** 주소 앞머리 → 시·도 코드 (동기화 범위만) */
|
||||
const SIDO_ADDRESS_PREFIXES: Array<[RegExp, string]> = [
|
||||
[/^대구/, '27'],
|
||||
[/^(경상북도|경북)/, '47'],
|
||||
];
|
||||
|
||||
/** 주소('대구광역시 중구 …')에서 시·도 코드를 찾는다. 모르는 지역이면 null */
|
||||
export function sidoCodeFromAddress(address: string | null): string | null {
|
||||
const text = address?.trim() ?? '';
|
||||
return SIDO_ADDRESS_PREFIXES.find(([re]) => re.test(text))?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** 좌표가 그 시·도 안인지 본다. 영역을 모르는 코드면 한국 안인지만 본다 */
|
||||
export function isInSido(sidoCode: string, lat: number, lng: number): boolean {
|
||||
if (!isInKorea(lat, lng)) return false;
|
||||
const b = SIDO_BOUNDS[sidoCode];
|
||||
if (!b) return true;
|
||||
return (
|
||||
lat >= b.latMin && lat <= b.latMax && lng >= b.lngMin && lng <= b.lngMax
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user