10 Commits

Author SHA1 Message Date
ttipo 94c8693dad test(backend): 에러 코드 계약 테스트의 타입 오류 수정
ERROR_CODES 는 코드마다 리터럴 타입이 달라 선택 속성 clientOnly 를 바로 읽으면 tsc 가 실패했다
(jest 는 타입 검사를 하지 않아 통과하고 있었다). 공통 정의 타입으로 넓혀 읽는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 4b6eecd679 docs(rules): 유예 취약점과 잠금 파일 npm 버전 주의 기록
- security.md: 남은 36건(glob·minimatch 보통, deepmerge·jest 낮음)의 도달 가능성·유예 이유·다시 볼 조건,
  multer ^2.5.0 · swagger 아래 js-yaml override 근거
- nestjs 스킬: 컨테이너 npm 11.20 이 지운 선택 peer 항목 때문에 CI(npm 10)의 npm ci 가 깨질 수 있다 —
  npx npm@10 ci --dry-run 으로 확인하는 절차

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 3e2adea95c chore(backend): 의존성 취약점 정리(치명 4·높음 3 → 0)
npm audit 47건(치명 4 · 높음 3) 중 범위 안에서 올릴 수 있는 것을 모두 올렸다.

- 컨테이너 안에서 npm audit fix: proxy-addr·piscina·handlebars·decompress·body-parser·qs 등
- Nest 11 최신판(11.2.8)으로 업데이트: 낡은 path-to-regexp 8.2.0(높음) 고정 해소
- overrides: multer ^2.5.0(2.4.0 에 DoS 취약점, platform-express 12.3.2 가 2.5.0 선언),
  @nestjs/swagger 아래 js-yaml ^5.4.3(5.3.0 고정, swagger 12.1.0 이 5.5.0 선언)

컨테이너 npm 11.20 이 선택 peer 의존성 항목(chokidar 4.0.3)을 잠금 파일에서 지워
CI 와 같은 npm 10 의 npm ci 가 실패해서, 그 항목만 이전 잠금 파일에서 되살렸다.
npm 10 · 11.6 · 11.20 모두 npm ci 통과, lint · 단위 185 · e2e 39 · 빌드 · 서버 기동(/api-docs-yaml 포함) 확인.
남은 36건(보통 33 · 낮음 3)은 메이저 업그레이드로만 풀려 security.md 에 유예로 기록한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 307204ef09 fix(backend): KOPIS 공연 제목의 지역 꼬리표 제거
순회 공연은 제목 끝에 "[대구]"·"[대구 북구]"·"[안동] (11월)" 같은 지역 표시가 붙어 온다
(대구·경북 244건 중 203건). 앱 목록에서는 군더더기라 저장할 때 뗀다.
꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)" 같은 메모는 남긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:13:03 +09:00
ttipo e7c2506ce7 chore: 공연·전시 API 진단 스크립트 추가
KOPIS(https·조회 기간·건수·시설 좌표·afterdate)와 한눈에보는문화정보(건수·분야·좌표 채움)를
구현 전에 실제로 불러 확인한다. 키는 backend env 에서 읽고 출력하지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:07:44 +09:00
ttipo ec4a2a3db8 docs: 공연·전시 출처 조사와 KOPIS 동기화 설계 정리
- 조사 문서: KOPIS·한국문화정보원·대구문화예술진흥원 이용 조건·필드·한도, 사용자 확인(KOPIS 저장·포스터 재사용 가능), 2026-11-03 진단 결과
- place-db.md v1.7: 여러 출처 스키마(§2.4), KOPIS 동기화 순서·좌표 검사·호출량·알려진 한계(§12.1), 다음 출처(§12.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:07:44 +09:00
ttipo 45db80d683 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>
2026-11-03 22:02:31 +09:00
ttipo d3d66444bc feat(backend): 행사 출처 구분·전시 종류·공연장 테이블 추가
KOPIS 공연·한국문화정보원 전시를 관광공사 행사와 같은 테이블에 받으려면
관광공사 contentid 단독 유니크로는 출처가 섞인다. (source, source_id) 로 바꾸고,
KOPIS 공연은 좌표가 공연장 상세에만 있어 event_venue 에 따로 둔다.

- 전시는 몇 달씩 하는 것이 보통이라 상시 프로그램 기간 상한을 종류별로 나눔(전시 183일)
- 관광공사 동기화는 관광공사 행사만 숨기도록 범위를 좁힘
- 조회 API 에 장르·예매 링크·출처별 표기 추가, 없는 날짜(2030-13-01)가 500 이 되던 것을 VAL_001 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:01:23 +09:00
ttipo 009a287533 feat(backend): 축제·행사 범위를 대구 근교(경북)까지 넓힘
관광공사에 등록된 앞으로 90일 행사가 대구 1건·경북 7건뿐이라(조회 조건 문제 아님)
홈 '이번 주말 축제·행사'가 대부분 비게 된다. 근교 행사를 거리와 함께 보여 준다.

- place:sync -- event 가 기본으로 대구(27)·경북(47)을 받는다(--regions= 로 변경,
  시·도마다 실행 기록을 따로 남긴다)
- GET /api/events 반경 상한 50km → 150km(포항 약 85km·청송 약 100km). 기본 5km 는 그대로
- 설계 문서에 화면 규칙: 홈은 동네 좌표 120km 거리순(대구 먼저), 이번 주말이 비면
  같은 반경 30일로 '다가오는 축제·행사'. 장소 상세 '근처'는 5km, 없으면 칸 숨김

2026-11-03 실측: 이번 주말 대구 도심 120km 안 5건(수성국제비엔날레 5km 가 맨 앞).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:44:28 +09:00
ttipo 16757b31f2 chore: 축제 지역 조회 진단 스크립트 추가
대구 축제·행사가 90일 동안 1건만 잡힌 이유가 데이터 부족인지 조회 조건 때문인지
가르려고 만들었다(지역+종료일 / 지역만 / 전국 비교, searchFestival2 5~6회).
2026-11-03 결과: 조건 문제는 없고(코드 빈 행사 0건) 등록된 대구 행사가 실제로 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:09 +09:00
33 changed files with 4076 additions and 1137 deletions
+13 -3
View File
@@ -85,7 +85,12 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (2026-11-03 backend `npm audit` 36건: 보통 33 · 낮음 3, 높음·치명 0)
>
> | 항목 | 경로 | 도달 가능성 | 미루는 이유 | 다시 볼 조건 |
> | --- | --- | --- | --- | --- |
> | `glob`·`minimatch` (보통) | typeorm(엔티티·마이그레이션 경로 탐색), jest·eslint·@nestjs/cli | 패턴이 코드에 고정된 값이라 외부 입력이 닿지 않는다. 나머지는 개발 도구 | 수정판이 typeorm 1.x 등 메이저 업그레이드뿐 | typeorm 1.x 전환 · jest/eslint 메이저 업그레이드 때 |
> | `deepmerge` · `jest` (낮음) | @nestjs/cli, jest | 개발 도구 전용(운영 이미지에 없음) | 수정판이 메이저 업그레이드뿐 | @nestjs/cli 12 · jest 다음 메이저 때 |
### 전이 의존성 취약점은 overrides 로 끊는다
@@ -95,18 +100,23 @@
```jsonc
// backend/package.json
"overrides": {
"multer": "^2.4.0"
"multer": "^2.5.0",
"@nestjs/swagger": { "js-yaml": "^5.4.3" } // 특정 패키지 아래에서만 올릴 때
}
```
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
>
> 2026-11-03 갱신: `multer@2.4.0` 에도 DoS 취약점(high)이 나와 `^2.5.0` 으로 올렸다(platform-express 12.3.2 가 2.5.0 을 선언).
> `@nestjs/swagger@11.4.7` 은 `js-yaml@5.3.0`(보통)을 고정한다. swagger 12.1.0 이 5.5.0 을 선언하므로 swagger 아래에서만 올렸다
> — 최상위로 올리면 `js-yaml@4` 를 쓰는 eslint 까지 메이저가 바뀐다.
규칙:
- override 는 **상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인**하고 적용한다
(위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
(위 사례는 platform-express v12 가 2.5.0 을, swagger 12.1.0 이 js-yaml 5.5.0 을 선언하는 것이 근거).
- 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
- 적용 후 **lint·테스트·빌드·런타임 기동까지 확인**한다. override 는 npm 이 검증해 주지 않는다.
- 상위 패키지를 정식으로 올릴 때 **override 를 제거**한다. 남겨두면 나중에 버전이 거꾸로 고정된다.
+5 -1
View File
@@ -298,7 +298,11 @@ dc exec backend npm uninstall <패키지>
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
현재 `multer: ^2.5.0` 과 `@nestjs/swagger` 아래 `js-yaml: ^5.4.3` 이 걸려 있다 — 각각 `@nestjs/platform-express`·`@nestjs/swagger` 를 v12 로 올릴 때 제거한다.
- **컨테이너 npm(11.20) 이 만든 잠금 파일이 CI 의 npm 10 에서 깨질 수 있다.** 새 npm 은 선택 peer 의존성(chokidar 등)·
다른 플랫폼용 선택 의존성 항목을 잠금 파일에서 지우는데, npm 10 의 `npm ci` 는 그 항목이 없으면 실패한다(2026-11-03).
의존성을 바꾼 뒤 호스트에서 `npx -y npm@10 ci --dry-run` 으로 확인하고, 실패하면 지워진 항목을 이전 잠금 파일에서 되살린다
(또는 컨테이너에서 `npx -y npm@11.6.2 install --package-lock-only` 로 다시 계산).
---
+4
View File
@@ -22,6 +22,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다.
# 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
+4
View File
@@ -20,6 +20,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
+1315 -1081
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -43,6 +43,7 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"fast-xml-parser": "^5.11.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -81,7 +82,10 @@
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
"multer": "^2.5.0",
"@nestjs/swagger": {
"js-yaml": "^5.4.3"
}
},
"jest": {
"moduleFileExtensions": [
+31 -5
View File
@@ -5,8 +5,10 @@ import { WinstonModule } from 'nest-winston';
import {
DEFAULT_EVENT_LOOKAHEAD_DAYS,
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';
@@ -24,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)
@@ -37,7 +39,10 @@ const USAGE = `사용법: place:sync <작업> [옵션]
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
@@ -49,6 +54,7 @@ const JOBS = [
'tour-pet',
'store',
'event',
'kopis',
'dedupe',
'reparse-hours',
'status',
@@ -61,6 +67,7 @@ function parseArgs(argv: string[]): {
skipDetails: boolean;
signgu: string[];
days: number;
regions: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
@@ -80,23 +87,40 @@ function parseArgs(argv: string[]): {
const days = daysArg
? Number(daysArg.slice('--days='.length))
: DEFAULT_EVENT_LOOKAHEAD_DAYS;
const regionsArg = rest.find((a) => a.startsWith('--regions='));
const regions = regionsArg
? regionsArg
.slice('--regions='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_EVENT_REGIONS;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
days,
regions,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails, signgu, days } = parseArgs(
const { job, region, skipDetails, signgu, days, regions } = parseArgs(
process.argv.slice(2),
);
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
const validDays = Number.isInteger(days) && days >= 1 && days <= 365;
if (!job || !/^\d{2}$/.test(region) || !validSigngu || !validDays) {
const validRegions =
regions.length > 0 && regions.every((v) => /^\d{2}$/.test(v));
if (
!job ||
!/^\d{2}$/.test(region) ||
!validSigngu ||
!validDays ||
!validRegions
) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
@@ -113,7 +137,9 @@ async function main(): Promise<void> {
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).run({ regionCode: region, days });
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') {
@@ -7,6 +7,7 @@ import {
isErrorCode,
resolveCodeByStatus,
} from './error-codes';
import type { ErrorCodeDefinition } from './error-codes';
import { BusinessException } from './business.exception';
describe('에러 코드 레지스트리', () => {
@@ -45,7 +46,8 @@ describe('에러 코드 레지스트리', () => {
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
// 코드마다 리터럴 타입이 달라 공통 정의 타입으로 넓혀야 선택 속성(clientOnly)을 읽을 수 있다
const clientOnlyCodes = Object.entries<ErrorCodeDefinition>(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.map(([code]) => code);
@@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 행사 출처를 여럿 받을 준비(KOPIS 공연·한국문화정보원 전시 등):
* - festival_event.source + (source, source_id) 유니크 — 관광공사 contentid 단독 유니크였다
* - 장르·예매 링크·공연장(event_venue) — KOPIS 공연은 좌표가 공연장 상세에만 있다
* - 행사 종류에 'exhibition', 동기화 작업에 'kopis'·'kcisa' 추가
*
* ⚠️ CHECK 제약 변경(ck_festival_event_kind·ck_place_sync_run_job)은 migration:generate 가 감지하지 못해 직접 적었다.
* 기존 행사는 기본값으로 source='tour' 가 된다.
*/
export class AddEventSourceAndVenue1793704967449 implements MigrationInterface {
name = 'AddEventSourceAndVenue1793704967449';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "event_venue" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "name" text NOT NULL, "address" text, "lat" double precision, "lng" double precision, "phone" text, "homepage" text, "synced_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_event_venue_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_event_venue_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca')), CONSTRAINT "PK_128347780df4ef90d1426da6c77" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source" text NOT NULL DEFAULT 'tour'`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "genre" text`);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "booking_url" text`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "venue_id" uuid`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "uq_festival_event_source" UNIQUE ("source", "source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_93324676cbe20bf271722f548b0" FOREIGN KEY ("venue_id") REFERENCES "event_venue"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
// 직접 적은 CHECK 제약 변경
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event', 'exhibition'))`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure', 'kopis', 'kcisa'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 되돌리기 전에 kopis·kcisa 실행 기록과 전시·다른 출처 행사를 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "FK_93324676cbe20bf271722f548b0"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "uq_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "venue_id"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "booking_url"`,
);
await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "genre"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source"`,
);
await queryRunner.query(`DROP TABLE "event_venue"`);
}
}
@@ -22,6 +22,13 @@ export class EventListItemDto {
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-12-06' })
endDate!: string;
@ApiProperty({
description: '장르(연극·뮤지컬·클래식·전시 …)',
nullable: true,
example: '뮤지컬',
})
genre!: string | null;
@ApiProperty({ description: '오늘(한국 날짜) 진행 중인지', example: false })
ongoing!: boolean;
@@ -87,6 +94,9 @@ export class EventDetailDto extends EventListItemDto {
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '예매·관람 안내 링크', nullable: true })
bookingUrl!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@@ -20,12 +20,11 @@ import type { EventKind } from '../place.constants';
/** 기간 기본값·한도(일) */
export const EVENT_RANGE_DEFAULT_DAYS = 30;
export const EVENT_RANGE_MAX_DAYS = 92;
/** 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의) */
export const EVENT_MAX_SPAN_DAYS = 60;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
export const EVENT_RADIUS_MAX_M = 50_000;
/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */
export const EVENT_RADIUS_MAX_M = 150_000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -57,7 +56,7 @@ export class EventSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사',
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사 · exhibition 전시',
enum: EVENT_KINDS,
isArray: true,
example: 'festival,event',
@@ -0,0 +1,60 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { EVENT_SOURCES, sqlIn } from '../place.constants';
import type { EventSource } from '../place.constants';
/**
* 공연장·전시장 — 공연 목록에는 좌표가 없고 공연장 상세에만 있는 출처(KOPIS 공연시설 mt10id)를 위해 따로 둔다.
* 새 공연장이 나올 때만 상세를 부르고, 한 달에 한 번 갱신한다. 좌표는 시설 단위다(홀이 여러 개여도 하나).
*/
@Entity()
@Unique('uq_event_venue_source', ['source', 'sourceId'])
@Check('ck_event_venue_source', sqlIn('source', EVENT_SOURCES))
export class EventVenue {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
source!: EventSource;
/** 원본 시설 id — KOPIS mt10id 등 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
address!: string | null;
/** 좌표가 없는 시설도 있다(그 공연은 근처 조회에서만 빠진다) */
@Column({ type: 'double precision', nullable: true })
lat!: number | null;
@Column({ type: 'double precision', nullable: true })
lng!: number | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
/** 시설 상세를 받은 시각 */
@Column({ type: 'timestamptz', nullable: true })
syncedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -7,26 +7,37 @@ import {
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
EVENT_KINDS,
EVENT_SOURCES,
EVENT_STATUSES,
IMAGE_LICENSES,
sqlIn,
} from '../place.constants';
import type { EventKind, EventStatus, ImageLicense } from '../place.constants';
import type {
EventKind,
EventSource,
EventStatus,
ImageLicense,
} from '../place.constants';
import { EventVenue } from './event-venue.entity';
import { Place } from './place.entity';
/**
* 축제·공연·행사(관광공사 searchFestival2). 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회할 때 적용한다.
* 축제·공연·행사·전시. 출처는 관광공사(searchFestival2)·KOPIS(공연)·한국문화정보원(전시) 등이다.
* 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "상시 프로그램 제외"(종류별 기간 상한, EVENT_MAX_SPAN_DAYS)는 저장할 때가 아니라 조회할 때 적용한다.
*/
@Entity({ name: 'festival_event' })
@Unique('uq_festival_event_source', ['source', 'sourceId'])
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Index('ix_festival_event_region', ['ldongRegnCd'])
@Check('ck_festival_event_source', sqlIn('source', EVENT_SOURCES))
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
@@ -38,17 +49,36 @@ export class FestivalEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 관광공사 contentid */
@Column({ type: 'text', unique: true })
@Column({ type: 'text', default: 'tour' })
source!: EventSource;
/** 원본 id — 관광공사 contentid · KOPIS mt20id · 문화정보원 seq. (source, sourceId) 가 유니크 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
kind!: EventKind;
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제) */
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제 · KOPIS 장르코드 · 문화정보원 realmCode) */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 장르 표시용(연극·뮤지컬·클래식·전시 …) */
@Column({ type: 'text', nullable: true })
genre!: string | null;
/** 예매·관람 안내 링크 */
@Column({ type: 'text', nullable: true })
bookingUrl!: string | null;
/** 공연장·전시장(좌표를 공연장에서 얻는 출처가 있다 — KOPIS 공연시설) */
@Column({ type: 'uuid', nullable: true })
venueId!: string | null;
@ManyToOne(() => EventVenue, { nullable: true })
@JoinColumn({ name: 'venue_id' })
venue?: EventVenue | null;
@Column({ type: 'text' })
name!: string;
+22 -5
View File
@@ -10,23 +10,39 @@ import { BusinessException } from '@/common/errors/business.exception';
import { kstDateString } from '@/shared/utils/kst';
import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto';
import {
EVENT_MAX_SPAN_DAYS,
EVENT_RADIUS_DEFAULT_M,
EVENT_RANGE_DEFAULT_DAYS,
EVENT_RANGE_MAX_DAYS,
EventSearchQueryDto,
} from './dto/event-search-query.dto';
import { FestivalEvent } from './entities/festival-event.entity';
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
import type { EventSource } from './place.constants';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
const DAY_MS = 86_400_000;
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 출처 표기(행사 상세 하단·앱 정보 화면) */
const PROVIDER_LABEL: Record<EventSource, string> = {
tour: '한국관광공사',
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
kcisa: '한국문화정보원 문화포털',
dgfca: '대구문화예술진흥원',
};
/** 종류별 기간 상한 SQL — 상수(EVENT_MAX_SPAN_DAYS)로만 만든다 */
const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS)
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
.join(' ')} END`;
/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */
function daysBetween(a: string, b: string): number {
const ta = Date.parse(`${a}T00:00:00Z`);
const tb = Date.parse(`${b}T00:00:00Z`);
// '2030-13-01' 처럼 해석이 안 되면 NaN — toISOString 이 예외를 던지지 않게 먼저 거른다
if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.NaN;
const validA = new Date(ta).toISOString().startsWith(a);
const validB = new Date(tb).toISOString().startsWith(b);
return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN;
@@ -83,9 +99,8 @@ export class FestivalService {
.createQueryBuilder('e')
.where('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
.andWhere('(e.end_date - e.start_date) <= :maxSpan', {
maxSpan: EVENT_MAX_SPAN_DAYS,
});
// 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게)
.andWhere(`(e.end_date - e.start_date) <= ${MAX_SPAN_SQL}`);
if (query.kind?.length) {
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
}
@@ -143,9 +158,10 @@ export class FestivalService {
playtimeText: event.playtimeText,
homepage: event.homepage,
phone: event.phone,
bookingUrl: event.bookingUrl,
imageUrl: event.imageUrl,
attribution: {
provider: '한국관광공사',
provider: PROVIDER_LABEL[event.source],
asOf: event.sourceModifiedAt
? new Date(event.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
@@ -164,6 +180,7 @@ export class FestivalService {
id: e.id,
name: e.name,
kind: e.kind,
genre: e.genre,
startDate: e.startDate,
endDate: e.endDate,
ongoing: e.startDate <= today && today <= e.endDate,
+27 -2
View File
@@ -75,11 +75,34 @@ export type I18nOrigin = (typeof I18N_ORIGINS)[number];
export const IMAGE_LICENSES = ['Type1', 'Type3'] as const;
export type ImageLicense = (typeof IMAGE_LICENSES)[number];
/** 관광공사 lclsSystm2: EV01 축제 · EV02 공연 · EV03 행사 */
export const EVENT_KINDS = ['festival', 'performance', 'event'] as const;
/** 행사 종류 — 관광공사 lclsSystm2(EV01 축제 · EV02 공연 · EV03 행사) + 전시(KOPIS 장르는 genre 에 따로 둔다) */
export const EVENT_KINDS = [
'festival',
'performance',
'event',
'exhibition',
] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export const EVENT_STATUSES = ['active', 'hidden'] as const;
/**
* 행사 출처 — tour 한국관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원.
* (출처, 원본 id) 가 행사·공연장의 연결 키다.
*/
export const EVENT_SOURCES = ['tour', 'kopis', 'kcisa', 'dgfca'] as const;
export type EventSource = (typeof EVENT_SOURCES)[number];
/**
* 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의는 60일).
* 전시는 몇 달씩 하는 것이 보통이라 길게 잡는다 — 그대로 두면 전시 대부분이 빠진다.
*/
export const EVENT_MAX_SPAN_DAYS: Record<EventKind, number> = {
festival: 60,
performance: 60,
event: 60,
exhibition: 183,
};
export type EventStatus = (typeof EVENT_STATUSES)[number];
export const SYNC_JOBS = [
@@ -89,6 +112,8 @@ export const SYNC_JOBS = [
'store',
'event',
'closure',
'kopis',
'kcisa',
] as const;
export type SyncJob = (typeof SYNC_JOBS)[number];
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from './entities/event-venue.entity';
import { FestivalEvent } from './entities/festival-event.entity';
import { PlaceCuration } from './entities/place-curation.entity';
import { Place } from './entities/place.entity';
@@ -24,6 +25,7 @@ import { PlaceService } from './place.service';
PlaceI18n,
PlaceListing,
FestivalEvent,
EventVenue,
PlaceSyncRun,
]),
],
@@ -16,6 +16,12 @@ import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
/**
* 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다.
* 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다.
*/
export const DEFAULT_EVENT_REGIONS = ['27', '47'];
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90;
/** 관광공사 축제·공연·행사 관광타입 */
@@ -85,6 +91,18 @@ export class FestivalSync {
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<FestivalSyncResult[]> {
const results: FestivalSyncResult[] = [];
for (const regionCode of options.regionCodes) {
results.push(await this.run({ regionCode, days: options.days }));
}
return results;
}
async run(options: {
regionCode: string;
days?: number;
@@ -123,6 +141,7 @@ export class FestivalSync {
await this.syncDetails(api, budget, regionCode, window.from, result);
}
result.detailRemaining = await this.events.countBy({
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
@@ -155,7 +174,9 @@ export class FestivalSync {
result: FestivalSyncResult,
): Promise<void> {
const runStart = new Date();
// 관광공사 행사만 다룬다(KOPIS 공연 등 다른 출처 행사는 각자의 동기화가 관리한다)
const existing = await this.events.find({
where: { source: 'tour' },
select: {
id: true,
sourceId: true,
@@ -211,6 +232,7 @@ export class FestivalSync {
mapped.sourceModifiedAt?.getTime();
rows.push({
...mapped,
source: 'tour',
ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
status: 'active',
lastSeenAt: runStart,
@@ -249,7 +271,8 @@ export class FestivalSync {
.createQueryBuilder()
.update(FestivalEvent)
.set({ status: 'hidden' })
.where('ldong_regn_cd = :region', { region: regionCode })
.where('source = :source', { source: 'tour' })
.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)', {
@@ -270,7 +293,7 @@ export class FestivalSync {
.insert()
.into(FestivalEvent)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source_id'])
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
@@ -284,6 +307,7 @@ export class FestivalSync {
): Promise<void> {
const pending = await this.events.find({
where: {
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
@@ -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,240 @@
import {
kopisDate,
kopisDetail,
kopisImageUrl,
kopisListItemToEvent,
kopisTimestamp,
kopisVenue,
stripRegionTag,
} 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,
});
});
});
describe('stripRegionTag', () => {
it('제목 끝의 지역 꼬리표를 뗀다(2026-11-03 실제 제목)', () => {
expect(stripRegionTag('굿프렌즈 [대구]')).toBe('굿프렌즈');
expect(
stripRegionTag('데뷔 60주년 기념공연, 남진 전국투어 콘서트 [대구 북구]'),
).toBe('데뷔 60주년 기념공연, 남진 전국투어 콘서트');
expect(
stripRegionTag('V.O.S 전국투어 콘서트: 제2회 동창회 [대구 경산]'),
).toBe('V.O.S 전국투어 콘서트: 제2회 동창회');
});
it('꼬리표 안팎의 괄호 메모(앵콜·회차)는 남긴다', () => {
expect(stripRegionTag('무명전설 전국투어 콘서트 [대구 (앵콜) ]')).toBe(
'무명전설 전국투어 콘서트 (앵콜)',
);
expect(stripRegionTag('Jazz in Blue 민경인 트리오 [안동] (11월)')).toBe(
'Jazz in Blue 민경인 트리오 (11월)',
);
expect(stripRegionTag('온 더 레드카펫 [대구] (11.10)')).toBe(
'온 더 레드카펫 (11.10)',
);
});
it('지역이 아닌 꼬리표·가운데 꼬리표·꼬리표뿐인 제목은 그대로 둔다', () => {
expect(stripRegionTag('라라라온 [롴돰 로컬다움]')).toBe(
'라라라온 [롴돰 로컬다움]',
);
expect(stripRegionTag('[대구] 송년 음악회')).toBe('[대구] 송년 음악회');
expect(stripRegionTag('[대구]')).toBe('[대구]');
});
});
@@ -0,0 +1,244 @@
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 = '공연완료';
/**
* 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]")에 쓰이는 이름 — 시·도와 대구·경북 시군구.
* 2026-11-03 대구·경북 공연 244건 중 203건에 붙어 있었다("[대구]" 141 · "[안동]" 15 · "[대구 북구]" …).
*/
const REGION_TAG_WORDS = new Set([
// 시·도
...['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종'],
...['경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'],
// 대구 구·군
...[
'중구',
'동구',
'서구',
'남구',
'북구',
'수성',
'수성구',
'달서',
'달서구',
],
...['달성', '달성군', '군위', '군위군'],
// 경북 시·군
...['포항', '경주', '김천', '안동', '구미', '영주', '영천', '상주', '문경'],
...['경산', '의성', '청송', '영양', '영덕', '청도', '고령', '성주', '칠곡'],
...['예천', '봉화', '울진', '울릉'],
]);
/** 제목 끝의 [꼬리표] 와 그 뒤에 붙는 회차 표시("(11월)", "(11.10)") */
const TRAILING_TAG_RE = /\s*\[([^[\]]*)\](\s*\([^()]*\))?\s*$/;
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;
}
/**
* 제목 끝의 지역 꼬리표를 뗀다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, 메모는 남긴다.
* "굿프렌즈 [대구]" → "굿프렌즈" · "무명전설 콘서트 [대구 (앵콜) ]" → "무명전설 콘서트 (앵콜)"
* "… 트리오 [안동] (11월)" → "… 트리오 (11월)" · "라라라온 [롴돰 로컬다움]" → 그대로
*/
export function stripRegionTag(name: string): string {
const m = TRAILING_TAG_RE.exec(name);
if (!m || m.index === 0) return name;
const notes = m[1].match(/\([^()]*\)/g) ?? [];
const words = m[1]
.replace(/\([^()]*\)/g, ' ')
.split(/\s+/)
.filter(Boolean);
if (words.length === 0 || !words.every((w) => REGION_TAG_WORDS.has(w))) {
return name;
}
const suffix = [...notes, m[2]?.trim()]
.filter((v): v is string => !!v)
.map((v) => v.replace(/\(\s*/, '(').replace(/\s*\)/, ')'))
.join(' ');
return [name.slice(0, m.index), suffix].filter(Boolean).join(' ').trim();
}
/** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function kopisListItemToEvent(
item: KopisItem,
): KopisListEvent | { skip: KopisSkipReason } {
const sourceId = str(item, 'mt20id');
const title = htmlToText(item.prfnm);
const name = title ? stripRegionTag(title) : null;
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;
const title = htmlToText(db.prfnm);
return {
name: title ? stripRegionTag(title) : null,
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);
}
}
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from '../entities/event-venue.entity';
import { FestivalEvent } from '../entities/festival-event.entity';
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';
@@ -16,7 +18,7 @@ import { StoreSync } from './store.sync';
import { TourPetSync } from './tour-pet.sync';
/**
* 장소 동기화 — 공공데이터를 place 에 적재한다.
* 장소·행사 동기화 — 공공데이터를 place·festival_event 에 적재한다.
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
*/
@Module({
@@ -26,6 +28,7 @@ import { TourPetSync } from './tour-pet.sync';
PlaceCuration,
PlaceSyncRun,
FestivalEvent,
EventVenue,
]),
],
providers: [
@@ -35,6 +38,7 @@ import { TourPetSync } from './tour-pet.sync';
TourPetSync,
StoreSync,
FestivalSync,
KopisSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
@@ -42,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;
+31 -1
View File
@@ -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);
});
});
+41
View File
@@ -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
);
}
+25 -5
View File
@@ -145,6 +145,14 @@ describe('축제·행사 조회 API (e2e)', () => {
endDate: '2030-05-21',
...at(null),
});
await save('E8', {
kind: 'exhibition',
name: '이투이 기획전시',
startDate: '2030-03-01',
endDate: '2030-07-31', // 5개월 — 전시는 기간 상한이 길어 목록에 남는다
...at(0.0025),
genre: '전시',
});
});
afterAll(async () => {
@@ -158,32 +166,43 @@ describe('축제·행사 조회 API (e2e)', () => {
it('기간과 겹치는 행사를 시작일순으로 주고, 상시 프로그램·숨김·기간 밖은 뺀다', async () => {
const res = await get(`/api/events?${may}`).expect(200);
expect(names(res)).toEqual([
'이투이 기획전시',
'이투이 봄꽃축제',
'이투이 먼축제',
'이투이 재즈공연',
'이투이 좌표없는행사',
]);
expect(list(res).data.pageInfo.total).toBe(4);
expect(list(res).data.pageInfo.total).toBe(5);
expect(list(res).data.items.every((i) => i.ongoing === false)).toBe(true);
});
it('좌표를 주면 반경 안 행사를 거리순으로 준다(좌표 없는 행사는 빠진다)', async () => {
const res = await get(`/api/events?${near}`).expect(200);
expect(names(res)).toEqual(['이투이 봄꽃축제', '이투이 재즈공연']);
expect(names(res)).toEqual([
'이투이 봄꽃축제',
'이투이 재즈공연',
'이투이 기획전시',
]);
expect(list(res).data.items[0].distanceM).toBeGreaterThan(100);
});
it('분류 필터', async () => {
const res = await get(`/api/events?${may}&kind=performance`).expect(200);
expect(names(res)).toEqual(['이투이 재즈공연']);
const ex = await get(`/api/events?${may}&kind=exhibition`).expect(200);
expect(names(ex)).toEqual(['이투이 기획전시']);
});
it('커서로 이어 받으면 겹치거나 빠지는 행사가 없다', async () => {
const p1 = await get(`/api/events?${may}&limit=2`).expect(200);
expect(names(p1)).toEqual(['이투이 봄꽃축제', '이투이 먼축제']);
const p1 = await get(`/api/events?${may}&limit=3`).expect(200);
expect(names(p1)).toEqual([
'이투이 기획전시',
'이투이 봄꽃축제',
'이투이 먼축제',
]);
const cursor = list(p1).data.pageInfo.nextCursor ?? '';
const p2 = await get(
`/api/events?${may}&limit=2&cursor=${encodeURIComponent(cursor)}`,
`/api/events?${may}&limit=3&cursor=${encodeURIComponent(cursor)}`,
).expect(200);
expect(names(p2)).toEqual(['이투이 재즈공연', '이투이 좌표없는행사']);
expect(list(p2).data.pageInfo.hasNext).toBe(false);
@@ -194,6 +213,7 @@ describe('축제·행사 조회 API (e2e)', () => {
['92일을 넘는 기간', 'from=2030-01-01&to=2030-06-30'],
['형식이 틀린 날짜', 'from=2030/05/01'],
['없는 날짜', 'from=2030-02-30&to=2030-03-01'],
['해석이 안 되는 날짜(13월)', 'from=2030-13-01&to=2030-12-31'],
['없는 분류', `${may}&kind=parade`],
['lat 만 보낸 경우', `${may}&lat=36.6`],
])('%s 는 VAL_001(400)', async (_, query) => {
+235
View File
@@ -0,0 +1,235 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module';
import { EventVenue } from '@/modules/place/entities/event-venue.entity';
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
import { CallBudget } from '@/modules/place/sync/call-budget';
import type {
KopisApi,
KopisItem,
} from '@/modules/place/sync/kopis-api.client';
import { KopisSync } from '@/modules/place/sync/kopis.sync';
import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory';
import { kstDateString } from '@/shared/utils/kst';
/**
* KOPIS 공연 동기화 — 가짜 KOPIS 응답으로 목록 upsert·상세·공연시설·좌표 복사·숨김·"바뀐 공연만 상세"를 실제 DB 로 검증한다.
* 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 'PFE2E'·'FCE2E' id 만 쓴다.
*/
const REGION = '99';
const kopisDay = (addDays: number) =>
kstDateString(new Date(), addDays).replace(/-/g, '.');
const listItem = (id: string, extra: Partial<KopisItem> = {}): KopisItem => ({
mt20id: id,
prfnm: `시험 공연 ${id}`,
prfpdfrom: kopisDay(0),
prfpdto: kopisDay(5),
fcltynm: '시험극장',
poster: `http://www.kopis.or.kr/upload/pfmPoster/PF_${id}.gif`,
genrenm: '연극',
prfstate: '공연예정',
...extra,
});
const DETAILS: Record<string, KopisItem> = {
PFE2E1: {
mt20id: 'PFE2E1',
mt10id: 'FCE2E1',
prfnm: '시험 공연 PFE2E1',
prfpdfrom: kopisDay(0),
prfpdto: kopisDay(5),
fcltynm: '시험극장 (소극장)',
pcseguidance: '전석 20,000원',
dtguidance: '금요일(19:30)',
prfage: '만 7세 이상',
updatedate: '2026-11-03 15:00:03.094823',
relates: {
relate: [{ relatenm: '예매', relateurl: 'https://ticket.test/1' }],
},
},
PFE2E2: {
mt20id: 'PFE2E2',
mt10id: 'FCE2E2',
prfnm: '시험 공연 PFE2E2',
prfpdfrom: kopisDay(10),
prfpdto: kopisDay(10),
fcltynm: '좌표가 틀린 극장',
},
};
const VENUES: Record<string, KopisItem> = {
// 주소 시·도를 모르는 곳 — 한국 안 좌표면 쓴다
FCE2E1: {
fcltynm: '시험극장',
adres: '시험시 시험구 1',
la: '36.6',
lo: '130.6',
telno: '053-000-0000',
},
// 대구 주소에 경주 좌표 — 비운다
FCE2E2: {
fcltynm: '좌표가 틀린 극장',
adres: '대구광역시 중구 북성로 96',
la: '35.847189',
lo: '129.212306',
},
};
/** 실행마다 바꿔 끼우는 가짜 응답 */
const fake = {
list: [] as KopisItem[],
changed: [] as KopisItem[],
};
function fakeApi(budget: CallBudget): KopisApi {
return {
listPerformances: (params) => {
budget.take('pblprfr');
if (params.signgucode !== REGION || params.cpage > 1) {
return Promise.resolve([]);
}
return Promise.resolve(params.afterdate ? fake.changed : fake.list);
},
getPerformance: (id) => {
budget.take('pblprfr-detail');
return Promise.resolve(DETAILS[id] ?? null);
},
getVenue: (id) => {
budget.take('prfplc-detail');
return Promise.resolve(VENUES[id] ?? null);
},
};
}
describe('KOPIS 공연 동기화 (e2e)', () => {
let moduleRef: TestingModule;
let ds: DataSource;
let sync: KopisSync;
const cleanup = async () => {
await ds.query(
`DELETE FROM festival_event WHERE source = 'kopis' AND source_id LIKE 'PFE2E%'`,
);
await ds.query(
`DELETE FROM event_venue WHERE source = 'kopis' AND source_id LIKE 'FCE2E%'`,
);
await ds.query(
`DELETE FROM place_sync_run WHERE job = 'kopis' AND scope = $1`,
[REGION],
);
};
const event = (id: string) =>
ds.getRepository(FestivalEvent).findOneByOrFail({
source: 'kopis',
sourceId: id,
});
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [PlaceSyncCliModule],
})
.overrideProvider(PublicDataApiFactory)
.useValue({
kopisDailyLimit: () => 1_000,
createKopis: (budget: CallBudget) => fakeApi(budget),
})
.compile();
await moduleRef.init();
ds = moduleRef.get(DataSource);
await ds.runMigrations();
await cleanup();
sync = moduleRef.get(KopisSync);
});
afterAll(async () => {
await cleanup();
await moduleRef.close();
});
it('첫 실행: 목록·상세·공연시설을 받고 시설 좌표를 공연에 복사한다', async () => {
fake.list = [
listItem('PFE2E1'),
listItem('PFE2E1'), // 같은 공연이 두 번
listItem('PFE2E2', { prfpdfrom: kopisDay(10), prfpdto: kopisDay(10) }),
listItem('PFE2E3', { prfstate: '공연완료' }),
listItem('PFE2E4', { area: '경상북도' }), // 다른 시·도
];
fake.changed = [];
const result = await sync.run({ regionCode: REGION });
expect(result).toMatchObject({
inserted: 2,
hidden: 0,
detailed: 2,
detailFailed: 0,
detailRemaining: 0,
venuesSynced: 2,
venueCoordsRejected: ['좌표가 틀린 극장'],
stoppedByBudget: false,
skipped: { duplicate: 1, ended: 1, other_region: 1 },
calls: { pblprfr: 2, 'pblprfr-detail': 2, 'prfplc-detail': 2 },
});
const e1 = await event('PFE2E1');
expect(e1).toMatchObject({
kind: 'performance',
ldongRegnCd: REGION,
status: 'active',
genre: '연극',
eventPlace: '시험극장 (소극장)',
feeText: '전석 20,000원',
playtimeText: '금요일(19:30)',
overview: '관람 연령: 만 7세 이상',
bookingUrl: 'https://ticket.test/1',
imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PFE2E1.gif',
lat: 36.6,
lng: 130.6,
address: '시험시 시험구 1',
phone: '053-000-0000',
});
expect(e1.detailSyncedAt).not.toBeNull();
const venue = await ds
.getRepository(EventVenue)
.findOneByOrFail({ id: e1.venueId! });
expect(venue).toMatchObject({ sourceId: 'FCE2E1', name: '시험극장' });
// 좌표가 주소와 맞지 않는 시설의 공연은 좌표 없이 남는다(근처 조회에서만 빠진다)
expect(await event('PFE2E2')).toMatchObject({
lat: null,
lng: null,
address: '대구광역시 중구 북성로 96',
});
});
it('바뀐 것이 없으면 상세·시설을 다시 받지 않고, 장소 이름도 목록 값으로 덮지 않는다', async () => {
const result = await sync.run({ regionCode: REGION });
expect(result).toMatchObject({
updated: 2,
inserted: 0,
detailed: 0,
venuesSynced: 0,
calls: { pblprfr: 2 },
});
expect((await event('PFE2E1')).eventPlace).toBe('시험극장 (소극장)');
});
it('목록에서 없어진 공연은 숨기고, 어제 이후 바뀐 공연은 상세를 다시 받는다', async () => {
fake.list = [listItem('PFE2E1')];
fake.changed = [listItem('PFE2E1')];
const result = await sync.run({ regionCode: REGION });
expect(result).toMatchObject({
hidden: 1,
changed: 1,
detailed: 1,
calls: { pblprfr: 2, 'pblprfr-detail': 1 },
});
expect((await event('PFE2E2')).status).toBe('hidden');
expect((await event('PFE2E1')).status).toBe('active');
});
});
+64 -11
View File
@@ -1,7 +1,7 @@
# 장소 DB 설계 (v1.5, 2026-11-03)
# 장소 DB 설계 (v1.7, 2026-11-03)
WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다.
1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11), 축제·행사 동기화·API(§12)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`.
1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11), 축제·행사 동기화·API와 KOPIS 공연 동기화(§12)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`.
이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다.
## 0. 전제 (이미 정한 것)
@@ -152,7 +152,7 @@ erDiagram
- 한국어는 `place` 본체에 둔다.
- 관광공사 영문은 국문과 연결 필드가 없어 **좌표(50m 안) + 분류 코드**로 맞춘다. 맞지 않은 영문 항목은 기록만 하고 붙이지 않는다(첫 적재 때 매칭률을 잰다).
### 2.4 `festival_event` — 축제·공연·행사
### 2.4 `festival_event` — 축제·공연·행사·전시
| 컬럼 | 설명 |
| --- | --- |
@@ -164,7 +164,11 @@ erDiagram
| `i18n` | jsonb `{en: {name, overview}}` |
| `status`, `last_seen_at` | |
- "60일 넘는 상시 프로그램 제외"는 **조회할 때** 적용한다(저장은 그대로).
- "상시 프로그램 제외"는 **조회할 때** 종류별 기간 상한으로 적용한다(저장은 그대로): 축제·공연·행사 60일, 전시 183일(`EVENT_MAX_SPAN_DAYS`).
- **여러 출처**(2026-11-03, 마이그레이션 `1793704967449-AddEventSourceAndVenue`): `source`(tour 관광공사 · kopis 공연예술통합전산망 ·
kcisa 한국문화정보원 · dgfca 대구문화예술진흥원) + `(source, source_id)` 유니크. 종류에 `exhibition`, 장르(`genre`)·예매 링크(`booking_url`)·
공연장(`venue_id` → `event_venue`) 추가. KOPIS 공연은 좌표가 공연장 상세에만 있어 `event_venue`(출처별 시설 id 유니크)에 캐시한다.
- 출처별 동기화는 자기 출처 행사만 숨긴다(관광공사 동기화가 KOPIS 공연을 숨기지 않게).
### 2.5 `place_sync_run` — 동기화 기록
@@ -234,13 +238,14 @@ erDiagram
| 반려동물 | 매주 | `detailPetTour2` contentId 없이 전체(약 9,700건, 10회) → contentid 로 붙임 | 10회 |
| 관광공사 영문 | 매주 | 영문 `areaBasedList2` → 좌표+분류로 매칭 → `place_i18n(en)` | |
| 상가정보 | 분기 | 대상 행정동 `storeListInDong`(divId=adongCd) × 데이트 업종 → bizesId 로 upsert. **새 스냅샷에 없는 ID → `gone`** | 도심·수성못·앞산 행정동 |
| 축제·행사 | 매일 | `searchFestival2`(오늘−60일 ~ 오늘+60일) | |
| 축제·행사 | 매일 | `searchFestival2`(오늘 ~ 오늘+90일, 대구+경북) | 대구 1 · 경북 7 (90일) |
| KOPIS 공연 | 매일 | `pblprfr`(오늘 ~ 오늘+90일) → 새·바뀐 공연만 상세 → 새 공연장만 시설 상세 | 대구 153 · 경북 91 (90일) |
| 폐업 대조 | 분기 | 행안부 인허가(휴게음식점·제과점·일반음식점) 영업상태와 주소·상호로 대조 → `gone` 후보 | 활용신청 후 |
- 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트).
- 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다.
### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물·상가정보)
### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물·상가정보·축제·KOPIS 공연)
```bash
# 개발 — 키가 있는 백엔드 컨테이너 안에서 (dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development)
@@ -248,7 +253,8 @@ dc exec backend npm run migration:run # 스키마 먼저
dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다
dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회)
dc exec backend npm run place:sync -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다
dc exec backend npm run place:sync -- event # 축제·공연·행사(대구, 오늘부터 90일 안). 매일. 새로 생기거나 바뀐 행사만 상세
dc exec backend npm run place:sync -- event # 축제·공연·행사(대구+경북, 오늘부터 90일 안). 매일. 새로 생기거나 바뀐 행사만 상세
dc exec backend npm run place:sync -- kopis # KOPIS 공연(대구+경북, 90일). 매일. KOPIS_SERVICE_KEY 필요. 첫 실행은 상세 250회 안팎(0.6초 간격, 약 4분)
dc exec backend npm run place:sync -- dedupe # 같은 가게 병합(키 불필요). tour-kor·store 가 끝날 때도 자동으로 돈다
dc exec backend npm run place:sync -- reparse-hours # 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요). 파서를 고친 뒤 한 번
dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수
@@ -352,7 +358,11 @@ node dist/commands/place-sync.js tour-kor
## 12. 축제·공연·행사 (구현됨)
**동기화** `place:sync -- event [--region=27] [--days=90]` (매일)
**동기화** `place:sync -- event [--regions=27,47] [--days=90]` (매일)
- **범위는 대구(27) + 경북(47)**: 2026-11-03 진단(`tools/data-check/festival_region_check.py`) 결과 앞으로 90일 관광공사 행사가
전국 106 · 대구 1 · 경북 7건이었다. 조회 조건 문제는 없었다(지역 코드 빈 행사 0건). 관광공사는 행사가 가까워져야
등록하는 경우가 많고 겨울은 행사 자체가 적다. 근교 행사를 거리와 함께 보여 주려고 경북까지 받는다.
- `searchFestival2` 를 오늘~N일 기간으로 부른다. 서버는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일).
- 지역 조건(`lDongRegnCd`)을 보내고, 응답에서도 다른 지역 행사는 한 번 더 거른다(서버가 조건을 무시해도 섞이지 않게).
@@ -366,8 +376,51 @@ node dist/commands/place-sync.js tour-kor
| 엔드포인트 | 내용 |
| --- | --- |
| `GET /api/events` | `from`·`to`(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · `lat`+`lng`+`radius`(기본 5km, 최대 50km) · `kind` · `cursor` · `limit`. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. **60일 넘는 상시 프로그램과 숨김 행사는 뺀다.** 항목에 `ongoing`(오늘 진행 중) |
| `GET /api/events` | `from`·`to`(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · `lat`+`lng`+`radius`(기본 5km, 최대 150km — 근교 경북까지) · `kind` · `cursor` · `limit`. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. **60일 넘는 상시 프로그램과 숨김 행사는 뺀다.** 항목에 `ongoing`(오늘 진행 중) |
| `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 |
- 화면 쓰임: 홈 '이번 주말 축제·행사' = 토·일 날짜로 `from`·`to`, 장소 상세 '근처 축제·행사' = 장소 좌표 + 반경.
- 계약 테스트: `backend/test/festival.e2e-spec.ts`(12개) — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
- **화면 규칙**(2026-11-03 합의):
- 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=120000 → 대구 행사가 먼저, 다음 경북(거리 표시 "대구에서 85km").
- **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다).
- 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다.
- 계약 테스트: `backend/test/festival.e2e-spec.ts` — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
### 12.1 KOPIS 공연 (구현됨)
**동기화** `place:sync -- kopis [--regions=27,47] [--days=90]` (매일, `KOPIS_SERVICE_KEY` 필요)
대구는 공연이 많은데 관광공사에는 거의 없다. 그래서 KOPIS 공연예술통합전산망을 공연 출처로 쓴다.
조사: `docs/research/pre-dev-check/culture_event_sources.md`. 2026-11-03 사용자 확인: KOPIS 데이터 저장·포스터 재사용 가능.
- **진단**(2026-11-03, `tools/data-check/culture_api_check.py`)
- 90일을 한 번에 조회할 수 있다(31일 제한 없음). https 동작.
- 앞으로 90일 공연: 대구 153건(클래식 62 · 대중음악 30 · 뮤지컬 25 · 연극 16 …), 경북 91건.
- 목록에는 공연시설 id·좌표·수정일이 없다. 그래서 상세와 시설 상세를 따로 부른다.
- 공연시설 좌표는 5곳 중 5곳에 있었다. 다만 1곳(대구 중구 주소)이 경주 좌표였다 → 아래 좌표 검사를 만든 이유.
- 순서(`sync/kopis.sync.ts`)
1. **목록** `pblprfr`(signgucode, stdate=오늘, eddate=오늘+N일, rows=100). 끝난 공연(`공연완료`)·다른 시·도·중복은 건너뛴다.
목록을 끝까지 받았으면 받은 기간 안에서 목록에 없는 공연을 숨긴다(원본 장애 판단은 §4.1).
2. **상세** `pblprfr/{mt20id}` — 새 공연, 어제 이후 등록·수정된 공연(목록 `afterdate`), 상세를 받은 지 7일 지난 공연만.
요금 `pcseguidance` → `fee_text`, 공연 시간 `dtguidance` → `playtime_text`, 예매처 첫 링크 → `booking_url`,
소개 = 줄거리 `sty` + 출연·관람 시간·관람 연령 줄. 장소 이름은 상세가 홀까지 주므로("대구콘서트하우스 (그랜드홀 …)") 상세 값을 쓴다.
3. **공연시설** `prfplc/{mt10id}` — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 30일 지난 시설만 → `event_venue`.
**좌표는 주소의 시·도 영역 안일 때만 쓴다**(`geo.ts` `isInSido`). 아니면 비우고 경고를 남긴다(그 공연은 근처 조회에서만 빠진다).
4. 시설의 좌표·주소·전화를 공연에 복사한다. 조회 API 가 공연 좌표로 거리를 계산하기 때문이다. 시설 좌표를 고치면 다음 실행에 공연도 바뀐다.
- 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]", "[대구 북구]", "[안동] (11월)")를 떼어 저장한다. 2026-11-03 244건 중 203건에 붙어 있었다.
꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)"·"(11.10)" 같은 메모는 남긴다(`kopis-mapper.ts` `stripRegionTag`).
- 포스터는 `http://www.kopis.or.kr/…` 로 온다. 앱이 http 이미지를 막으므로 `https://kopis.or.kr/…` 로 바꿔 저장한다(목록·상세 사진 모두 포스터).
- 호출: 0.6초 간격(KOPIS 는 초당 10회에서 차단). 오퍼레이션별(`pblprfr`·`pblprfr-detail`·`prfplc-detail`) 하루 상한 980.
공식 한도는 밝혀져 있지 않다. 이후 하루 호출은 목록 4회 + 상세 수십 회로 예상한다.
- 출처 표기: 조회 API 상세의 `attribution.provider` = "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)".
- 검증: `backend/test/kopis-sync.e2e-spec.ts`. 가짜 KOPIS 응답과 실제 DB 로 다음을 확인한다.
첫 적재와 시설 좌표 복사, 틀린 좌표 비우기 → 재실행 시 상세·시설 재조회 없음 → 바뀐 공연만 상세 재조회, 없어진 공연 숨김.
- 알려진 한계
- 관광공사 공연(EV02)과 같은 공연이 둘 다 나올 수 있다. 출처 간 같은 행사 병합은 아직 없다(조사 문서 3-3 의 규칙으로 나중에).
- '이번 주말'은 기간이 겹치는지만 본다. 평일만 하는 공연도 섞인다(`dtguidance` 요일 해석은 나중에).
- 기간이 60일 넘는 공연(대구 8건)은 상시 프로그램 규칙으로 목록에서 빠진다.
### 12.2 다음 출처
- **전시: 한국문화정보원 한눈에보는문화정보**(15138937). 진단 결과 앞으로 90일 대구 42건(전시 13 · 음악/콘서트 17 …), 경북 17건(전시 1)이다. 좌표 채움 38/42.
공연은 KOPIS 와 겹치므로 **전시(realm 전시)만** 받을 계획이다. 이미지 이용 조건은 아직 확인하지 않았다(조사 문서 4장 4번).
@@ -0,0 +1,273 @@
# 공연·전시 출처 조사 (KOPIS · 한국문화정보원 · 대구) — 2026-11-03
> 목적: 홈 '이번 주말 축제·행사'에 대구 공연·전시를 채울 공공 API 를 고른다. 관광공사 `searchFestival2` 에는 앞으로 90일 대구 행사가 1건뿐이다(`docs/design/place-db.md` §12).
> 전제: 서버가 매일 받아 자체 DB(`festival_event`)에 저장하고 목록·상세·근처(좌표 거리) 조회로 보여 준다. 출처 표시는 할 수 있다. 무료 상업 앱이고 나중에 수익화할 수 있다. 카카오·네이버 데이터는 저장하지 않는다.
>
> **표기**
> - **[확인: URL]** 이번 세션에 원문(페이지·PDF·가이드 문서)을 직접 열어 확인했다.
> - **[확인(검색 요약): URL]** KOPIS 사이트는 JS 렌더링이라 본문을 직접 읽지 못했다. 검색 엔진이 그 공식 페이지에서 뽑은 문구로만 확인했다. 원문 대조가 필요하다.
> - **[3rd-party: URL]** 다른 개발자의 GitHub 메모·코드. 공식 근거가 아니다.
> - **[관측: URL]** 키가 필요 없는 공개 엔드포인트·웹 목록을 2026-11-03 에 직접 불러 집계했다.
> - **[추정]** 내 판단이다. 검증이 필요하다.
>
> 키가 필요한 API(KOPIS, 공공데이터포털 `apis.data.go.kr`)는 호출하지 않았다. Chrome 확장은 연결되지 않았고, KOPIS 사이트 스크립트를 따로 받아 보는 방식은 쓰지 않았다.
---
## 0. 사용자 확인 (2026-11-03)
- 아래 4장의 1~3(KOPIS 인증키 신청·KOPIS 서면 문의·공공데이터포털 15138937·15138930 활용신청)을 사용자가 마쳤다.
- 사용자 확인: **데이터 저장 가능, 포스터 이미지 재사용 가능.** (KOPIS 서면 문의 결과로 받은 답이다. 답변 원문은 사용자가 보관 — 받은 문구를 이 문서에 붙여 두면 근거가 남는다.)
- 그래서 1·3장의 "포스터는 권리 확인 전까지 URL 만" 권고는 KOPIS 에 대해서는 풀린다. 한국문화정보원 이미지(공공누리 1유형이 아니면 사전 협의)는 별도로 확인한다.
- 진단 결과는 아래 0-1 에 있다. KOPIS 공연 동기화는 구현했다(`place-db.md` §12.1).
### 0-1. 진단 결과 (2026-11-03, `tools/data-check/culture_api_check.py`)
**KOPIS** — 호출 18회
- `https://kopis.or.kr/openApi/restful` 가 동작한다(https 확인).
- **90일을 한 번에 조회할 수 있다.** 대구 31일 92건, 90일 153건이었다. 31일 제한은 없다.
- 앞으로 90일 공연: 대구 153건, 경북 91건.
- 대구 장르: 서양음악(클래식) 62 · 대중음악 30 · 뮤지컬 25 · 연극 16 · 무용 7 · 국악 6 · 서커스/마술 5 · 복합 1.
- 상태: 공연예정 142 · 공연중 11. 오픈런 0건. 포스터는 153건 모두 있다. 기간이 2개월을 넘는 공연은 8건이다.
- 목록 칸: `mt20id`·`prfnm`·`prfpdfrom`·`prfpdto`·`fcltynm`·`poster`·`area`·`genrenm`·`openrun`·`prfstate`.
공연시설 id·수정일은 목록에 **없다**.
- 상세 5건에는 모두 요금·공연 시간·시설 id·수정일·예매처 1곳이 있었다. 줄거리(`sty`)는 1건에만 있었다.
- 공연시설 좌표는 5곳 모두 있었다. 그러나 **프로토타운 북성로 본부(대구 중구 주소)의 좌표가 경주(경도 129.21)였다.** 그래서 주소의 시·도와 좌표를 대조한다.
- `afterdate`(어제 이후 등록·수정) 조회가 동작한다(대구 12건).
**한눈에보는문화정보** — 호출 3회
- 앞으로 90일: 대구 42건, 경북 17건.
- 대구 분야: 음악/콘서트 17 · 전시 13 · 연극 5 · 교육/체험 4 · 뮤지컬/오페라 2 · 무용/발레 1.
- 경북 분야: 음악/콘서트 8 · 뮤지컬/오페라 3 · 행사/축제 3 · 연극 2 · 전시 1.
- 좌표 채움: 대구 38/42, 경북 14/17. 썸네일은 전부 있다.
- 상세 칸: `contents1`·`price`·`placeAddr`·`phone`·`imgUrl`·`url` 등.
- 제목에 `&amp;lsquo;` 같은 이중 인코딩이 보인다. 받을 때 엔티티를 한 번 더 풀어야 한다.
- 공연은 KOPIS 와 겹친다. 그래서 이 출처에서는 전시만 받는 것이 낫다 [추정].
## 1. 한 줄 결론
**공연은 KOPIS 를 주 출처로 쓸 만하다. 대구 공연이 분기 376건이고, 상업적 이용이 가능하며, 공연시설 상세 API 로 좌표를 얻을 수 있다. 전시는 한국문화정보원 '한눈에보는문화정보'(좌표 포함)와 대구문화예술진흥원 공연·전시 API(키 없음, 좌표·포스터 없음)로 보강한다. 막히는 조건은 두 가지다. ① 세 출처 모두 포스터 이미지의 재배포·변형 허락을 밝히지 않는다. ② KOPIS 약관 원문(저장·캐시 조항, 일일 한도)을 읽지 못했다.**
- 텍스트 데이터의 저장을 금지하는 조항은 어느 출처에서도 찾지 못했다. 그러나 KOPIS 는 약관 원문을 못 읽어서 "금지 조항이 없다"고 단정할 수 없다.
- 한국문화정보원 문화포털의 **회원 이용약관 제17조**는 "사전 승낙 없는 영리행위"를 금지한다. 같은 데이터를 공공데이터포털은 "이용허락범위 제한 없음"으로 내놓는다. 이 둘이 어긋나므로 확인이 필요하다(§2-1).
---
## 2. 출처별 표
열은 세 출처다.
- **KOPIS**: 공연예술통합전산망, (재)예술경영지원센터
- **문화정보원**: 한국문화정보원_한눈에보는문화정보조회서비스, data.go.kr 15138937
- **대구**: 대구광역시_공연·전시 정보, 대구문화예술진흥원 제공, data.go.kr 15084620
### 2-1. 이용 조건
| 항목 | KOPIS | 문화정보원 | 대구 |
| --- | --- | --- | --- |
| 이용허락범위 | 공공데이터포털 등록분(공연목록·공연상세·공연시설목록·공연시설상세·축제목록) 모두 "이용허락범위 제한 없음", 무료 [확인: https://www.data.go.kr/data/15097805/openapi.do · https://www.data.go.kr/data/15097844/openapi.do] | "이용허락범위 제한 없음", 무료 [확인: https://www.data.go.kr/data/15138937/openapi.do] | "이용허락범위 제한 없음", 무료 [확인: https://www.data.go.kr/data/15084620/openapi.do] |
| 공공누리 유형 | 표시를 찾지 못함 | API 데이터에는 표시가 없다. 문화포털 저작권정책은 "공공누리 제1유형이 붙은 저작물만" 별도 허락 없이 자유이용할 수 있다고 한다 [확인: https://www.culture.go.kr/portal/main/contents.do?menuNo=200159] | 표시를 찾지 못함 |
| 상업적 이용 | FAQ: "특별한 사유가 존재하지 않는 한 상업적 이용이 가능합니다" (공공데이터법 근거) [확인(검색 요약): https://kopis.or.kr/por/cs/openapi/openApiFaq.do?menuId=MNU_00074] | 포털 기준으로는 가능("제한 없음"). 다만 문화포털 회원약관 제17조는 "사전 승낙 없이 서비스를 이용하여 어떠한 영리행위도 할 수 없습니다"라고 한다 [확인: https://www.culture.go.kr/portal/main/contents.do?menuNo=200157]. 이 조항이 data.go.kr 로 받은 API 에도 적용되는지는 확인하지 못했다 | 포털 기준 가능("제한 없음") [확인: 위 URL] |
| 자체 DB 저장·캐시 | 허용·금지 조항 모두 찾지 못함. 미확인 | 금지 조항 없음. 포털은 "제한 없음"이고 업데이트 주기는 "실시간"이다 [확인: 위 URL]. 문화포털 회원약관 제17조 3항은 "얻은 정보를 사전승낙 없이 복사, 복제 … 타인에게 제공할 수 없습니다"라고 한다 [확인: 위 menuNo=200157] | 금지 조항 없음 [확인: 위 URL] |
| 출처 표시 | 의무: "KOPIS에 의거하여 개발된 프로그램 또는 서비스라는 점을 반드시 명시". 예시 문구는 "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)"이고, 출처를 표시하지 않으면 서비스가 중단될 수 있다 [확인(검색 요약): https://kopis.or.kr/por/cs/openapi/openApiInfo.do?menuId=MNU_00074] | API 자체에는 명시가 없다. 공공누리 저작물은 출처 표시가 필수이고, 예시 문구는 "본 저작물은 '문화포털'에서 서비스 되는 공공누리 제O유형으로 개방한 …"이다 [확인: 위 menuNo=200159] | 명시 없음. "대구문화예술진흥원" 표기를 권장 [추정] |
| 포스터 이미지 | 포스터 이용 조건을 밝힌 문구를 찾지 못함. 포스터는 제작·기획사의 저작물일 가능성이 높다 [추정] | `thumbnail`·`imgUrl`(포스터 등)을 준다. 공공누리 제1유형이 아니면 "담당자와 사전에 협의"해야 한다(문화공공데이터광장 저작권정책) [확인: https://www.culture.go.kr/data/page/guide/terms.do] | 이미지 필드가 없다 [관측: https://dgfca.or.kr/api/daegu/cultural-events] |
| 공통 원칙 | 공공데이터포털: "저작권 등 제3자 권리가 포함된 공공데이터는 권리자의 정당한 이용허락을 확보해야" 한다 [확인: https://www.data.go.kr/ugs/selectPortalPolicyView.do] | 같음 | 같음 |
| 기타 제약 | 인증키는 1인당 1개, 양도·공유 불가, PC에서만 발급·사용. 조회 한도를 넘으면 서비스가 중지된다 [확인(검색 요약): 위 openApiInfo·openApiFaq URL] | — | 사용자가 직접 등록하는 행사가 섞여 있다(설명: "문화행사 정보를 직접 등록할 수도 있습니다") [확인: 위 URL] |
### 2-2. 키 발급·한도
| 항목 | KOPIS | 문화정보원 | 대구 |
| --- | --- | --- | --- |
| 신청 방법 | kopis.or.kr Open API 메뉴에서 신청하면 **인증키가 이메일로 발급**된다 [확인(검색 요약): 위 openApiFaq URL]. data.go.kr 등록분은 LINK 형이라 키는 KOPIS 에서 받는다. 바로가기 주소는 `kopis.or.kr/por/cs/openapi/openApiList.do` [확인: https://www.data.go.kr/tcs/dss/selectApiLinkUrl.do?publicDataPk=15097805] | data.go.kr 로그인 → '활용신청' → 마이페이지에서 인증키 확인 [확인: https://www.culture.go.kr/industry/apiGuideA.do] | **키가 필요 없다.** 링크 주소 `https://dgfca.or.kr/api/daegu/cultural-events` 가 JSON 전체를 바로 준다 [관측: 같은 URL]. data.go.kr 표기는 "개발 자동승인 / 운영 심의승인"이다 [확인: 위 15084620 URL] |
| 승인 소요 | 미확인. 3rd-party 메모: "유효기간 1년, 3개월 이상 미사용 시 승인 취소" [3rd-party: https://raw.githubusercontent.com/sirloinbh/life-journal/main/docs/research/external-sources.md] | 개발·운영 모두 **자동승인** [확인: 위 15138937 URL] | 해당 없음 |
| 개인/개인사업자 | "1인당 1개"이므로 개인 신청 전제로 보인다 [추정]. 사업자 요건은 찾지 못함 | data.go.kr 은 개인 회원으로 신청할 수 있다 (`accounts_checklist.md` 결론 3) | 해당 없음 |
| 호출 한도 | FAQ: "1초당 10회 조회 시 서비스가 중지" [확인(검색 요약): 위 openApiFaq URL]. 일일 한도는 미확인. 3rd-party 실측: 7초 간격으로 72회 중 9회가 불규칙하게 차단됐고, 30초 뒤 재시도하면 성공했다 [3rd-party: https://github.com/jaefunk/assistant/pull/6] | 개발계정 **10,000건/일**. 운영계정은 활용사례를 등록하면 늘릴 수 있다 [확인: 위 15138937 URL]. 가이드: 초당 최대 30 tps, 평균 응답 500ms [확인: https://www.culture.go.kr/guide/docs/new_ppds_guide.zip] | 미확인. 한 번 호출에 전체(2.2MB, 5,259건)를 준다 [관측] |
| 요금 | 무료 [확인: 위 15097805 URL] | 무료 [확인] | 무료 [확인] |
| 형식·전송 | XML 만 제공(JSON 은 직접 변환) [확인(검색 요약): 위 openApiFaq URL]. 가이드상 운영 URL 이 `http://www.kopis.or.kr/openApi/restful/...` 이고 "전송레벨암호화 없음"이다 [확인: kopis_openapi_guide_v3.7.pdf, https://www.data.go.kr/data/15097841/openapi.do 참고문서] | XML, REST. 포털 주소는 `https://apis.data.go.kr/B553457/cultureinfo` 다 [확인: https://www.data.go.kr/data/15138937/openapi.do?format=md]. 가이드 문서에는 `cultrueinfo`(오타)로 적혀 있다 [확인: new_ppds_guide.zip] | JSON 배열 [관측] |
### 2-3. 제공 필드
| 항목 | KOPIS | 문화정보원 | 대구 |
| --- | --- | --- | --- |
| 오퍼레이션 | 공연목록 `pblprfr`, 공연상세 `pblprfr/{mt20id}`, 공연시설목록 `prfplc`, 공연시설상세 `prfplc/{mt10id}`, 축제목록 `prffest` 등 [확인: guide v3.7] | 기간별 `period2`, 지역별 `area2`, 분야별 `realm2`, 상세 `detail2?seq=`, 문화캘린더 `livelihood2` [확인: new_ppds_guide.zip] | 단일 엔드포인트 [관측] |
| 제목·기간 | `prfnm`, `prfpdfrom`/`prfpdto`(형식 `2016.05.12`) [확인: guide v3.7] | `title`, `startDate`/`endDate`(YYYYMMDD) [확인: new_ppds_guide.zip] | `subject`, `start_date`/`end_date`(YYYY-MM-DD) [관측] |
| 장소 이름 | `fcltynm`(공연시설명(공연장명)) [확인: guide v3.7] | `place` [확인] | `place`(예: "SOYO 갤러리") [관측] |
| 주소 | 공연에는 없다. 공연시설상세 `adres` 로 얻는다 [확인: guide v3.7] | 상세 `placeAddr`(공연장 주소), `placeSeq`(문화예술공간 일련번호) [확인] | 없다. 구 이름(`event_area`)만 있다 [관측] |
| **좌표** | **공연에는 없다.** 공연상세 `mt10id`(공연시설 ID) → 공연시설상세의 `la`(위도)·`lo`(경도). 예: 올림픽공원 37.52112 / 127.12836 [확인: guide v3.7] | **있다.** 목록·상세 모두 `gpsX`(경도)·`gpsY`(위도)를 준다. 좌표 범위 검색 `gpsxfrom/gpsyfrom/gpsxto/gpsyto` 도 된다. 항목구분이 0(선택)이라 비어 있을 수 있다 [확인: new_ppds_guide.zip] | **없다** [관측] |
| 장르 | `genrenm` + 장르코드 `shcate`: AAAA 연극, GGGA 뮤지컬, CCCA 서양음악(클래식), CCCC 한국음악(국악), CCCD 대중음악, BBBC 무용(서양/한국무용), BBBE 대중무용, EEEA 복합, EEEB 서커스/마술 [확인: https://kopis.or.kr/upload/openApi/%EA%B3%B5%EC%97%B0%EC%98%88%EC%88%A0%ED%86%B5%ED%95%A9%EC%A0%84%EC%82%B0%EB%A7%9DOpenAPI%EA%B3%B5%ED%86%B5%EC%BD%94%EB%93%9C.pdf]. **전시는 없다** | `realmName` + `realmCode`: A000 연극, B000 음악/콘서트, B002 국악, C000 무용/발레, **D000 전시**, B003 뮤지컬/오페라, E000 아동/가족, F000 행사/축제, G000 교육/체험 등. `serviceTp` 는 A 공연/전시, B 행사/축제, C 교육/체험 [확인] | `event_gubun`: 전시·공연·교육·체험·행사 [관측] |
| 지역 필터 | `signgucode`(시도 2자리: **대구 27**, 경북 47)·`signgucodesub`(구군 4자리: 2711 중구 … 2771 달성군). 우리 `lDongRegnCd` 27·47 과 같은 값이다 [확인: 공통코드 PDF] | `sido`(예: "부산"), `sigungu`(예: "동래구") 문자열 [확인: data.go.kr format=md] | 없다. 대구 전용이고 `event_area`(구)로 직접 거른다 [관측] |
| 포스터 URL | `poster`(예: `http://www.kopis.or.kr/upload/pfmPoster/…`), 소개이미지 `styurls` [확인: guide v3.7]. http→https 와 www 제거 리다이렉트가 일어난다 [3rd-party: https://github.com/Cominggg/Data/issues/90] | 목록 `thumbnail`, 상세 `imgUrl` [확인] | 없다 [관측] |
| 가격 | 상세 `pcseguidance`(예: "전석 30,000 원") [확인: guide v3.7] | 상세 `price`(예: "무료") [확인] | `pay_gubun`(유료/무료/빈값), `pay`(자유 문구) [관측] |
| 공연 시간 | 상세 `dtguidance`(자유 문구, 예: "화요일 ~ 금요일(20:00), 토요일(15:00,17:30,20:00)"), `prfruntime` [확인: guide v3.7] | 시간 필드 없음. `contents1` 본문에 섞여 있다 [확인] | `content` 본문(HTML `<br>`)에 섞여 있다 [관측] |
| 그 밖에 | `prfstate`(01 예정/02 공연중/03 완료), `openrun`, `relates`(예매처명·URL), `festival`(축제 여부), `updatedate`(최종수정일) [확인: guide v3.7] | `url`(관람 URL), `phone`, `placeUrl` [확인] | `homepage`, `contact`, `host`. **`contact` 에 개인 휴대전화가 들어 있는 경우가 있다**(예: 010-…) [관측] |
| 변경 감지 | 목록 `afterdate`: 해당 일자 이후 등록·수정된 항목만 준다 [3rd-party: https://github.com/coldsurfers/kopis-cli (types.ts, specs/phase5)]. 상세 `updatedate` [확인: guide v3.7] | 수정일 필드 없음 [확인] | 수정일 필드 없음. `event_seq` 는 증가하는 번호다(2299~8852) [관측] |
| 갱신 주기 | 포털 표기 "실시간" [확인: 위 15097805 URL]. KOPIS 는 197개 티켓 발권 시스템과 연계해 "매일 데이터를 수집"한다 [확인: 아래 3분기 보고서] | "실시간" [확인] | "실시간" [확인] |
### 2-4. 대구 데이터 양
| 출처 | 수치 | 근거 |
| --- | --- | --- |
| KOPIS | 2025년 3분기(7~9월), 수도권을 빼면 대구가 공연건수 1위: **376건**, 1,712회, 티켓 24만 8,324매. 장르별(서울 제외 순위): 클래식 167건(2위), 뮤지컬 58건(2위), 무용 18건(2위), 연극 공연회차 763회(1위) | [확인: http://www.gokams.or.kr/DATA/GO/2025%203%EB%B6%84%EA%B8%B0%20%ED%8B%B0%EC%BC%93%ED%8C%90%EB%A7%A4%20%ED%98%84%ED%99%A9%20%EB%B6%84%EC%84%9D%20%EB%B3%B4%EA%B3%A0%EC%84%9C.pdf] |
| KOPIS (환산) | 90일 창이면 대구 수백 건이 겹칠 것이다. 주말 이틀에도 수십 건이 나올 것이다 | [추정] |
| KOPIS 수집 범위 | "전산 발권된" 예매·취소 데이터다. 「공연법」 제4조로 전송 의무가 있지만 "전산예매시스템에 의하여 발권되지 아니한 경우"는 빠진다. 그래서 **무료·현장 공연은 누락될 수 있다** | [확인: 위 보고서 앞부분 '보고서 안내'·공연법 발췌] |
| 문화정보원 | 문화포털 웹 '한눈에 보는 문화정보'에서 지역을 대구로 고르면 **40건**(전국 3,597건)이다. 첫 화면 12건은 음악/콘서트 9, 전시 2, 뮤지컬 1이다. 웹 목록 기준이라 API 결과와 같다고 단정할 수 없다 | [관측: https://www.culture.go.kr/portal/cltInfo/oneCltInfo/list.do?menuNo=200010&ctpvNm=%EB%8C%80%EA%B5%AC] |
| 대구 | 전체 5,259건 중 2026-11-03 ~ 2027-02-01 과 기간이 겹치는 것은 **42건**이다(전시 21, 공연 16, 교육 2, 체험 2, 행사 1). 이번 주말(11/7~8)은 11건(전시 7, 공연 2, 교육 2). 42건 중 7건이 60일 넘는 장기 행사다. 2026년 시작 행사는 월 3~51건으로 들쭉날쭉하다 | [관측: https://dgfca.or.kr/api/daegu/cultural-events] |
| (비교) 관광공사 | 앞으로 90일 대구 1건 | `docs/design/place-db.md` §12 |
### 2-5. 원문 URL
- KOPIS
- Open API 안내: https://kopis.or.kr/por/cs/openapi/openApiInfo.do?menuId=MNU_00074 (JS 렌더링, 직접 열람 실패)
- FAQ: https://kopis.or.kr/por/cs/openapi/openApiFaq.do?menuId=MNU_00074 (같음)
- 서비스 목록: https://kopis.or.kr/por/cs/openapi/openApiList.do?menuId=MNU_00074
- 개발가이드 v3.7(PDF): https://www.data.go.kr/data/15097841/openapi.do 의 참고문서. 3rd-party 메모는 사이트 최신판을 v5.0 이라고 한다 [3rd-party: https://github.com/jaefunk/assistant/pull/4]
- 공통코드(PDF, 사이트 최신판): https://kopis.or.kr/upload/openApi/%EA%B3%B5%EC%97%B0%EC%98%88%EC%88%A0%ED%86%B5%ED%95%A9%EC%A0%84%EC%82%B0%EB%A7%9DOpenAPI%EA%B3%B5%ED%86%B5%EC%BD%94%EB%93%9C.pdf
- data.go.kr 등록분: 공연목록 15097805 · 공연상세 15097841 · 공연시설목록 15097806 · 공연시설상세 15097844 · 축제목록 15097808
- 문의: 공연정보팀 kopis@gokams.or.kr / 02-708-2256 [확인: 위 3분기 보고서]
- 문화정보원
- 포털: https://www.data.go.kr/data/15138937/openapi.do
- 사용가이드: https://www.culture.go.kr/industry/apiGuideA.do
- 개발가이드(zip, .doc): https://www.culture.go.kr/guide/docs/new_ppds_guide.zip
- 저작권정책: https://www.culture.go.kr/portal/main/contents.do?menuNo=200159
- 이용약관: https://www.culture.go.kr/portal/main/contents.do?menuNo=200157
- 문의: 문화포털팀 02-3153-2843
- 대구
- 포털: https://www.data.go.kr/data/15084620/openapi.do
- 엔드포인트: https://dgfca.or.kr/api/daegu/cultural-events
- 문의: 053-803-3738
- 공통: 공공데이터포털 이용허락범위·약관 https://www.data.go.kr/ugs/selectPortalPolicyView.do
### 2-6. 그 밖에 살펴본 출처 (짧게)
| 출처 | 판단 | 근거 |
| --- | --- | --- |
| 문화체육관광부_문화예술공연(통합) (data.go.kr 15121487 → 문화공공데이터광장 `CNV_060`) | **부적합.** `dtype`(분류)와 `title`(2자 이상)이 **필수**라서 전체를 받아 오기 어렵다. 지역·좌표 필드도 없다. 문화포털 게시물을 다시 내보내는 것이다 | [확인: https://www.culture.go.kr/data/openapi/openapiView.do?id=580] |
| 한국문화정보원 외_전시정보(통합) (`API_CCA_145`) | **보조로만.** 국립 기관 23곳의 전시다. 대구는 국립대구박물관 1곳뿐이고 지역·좌표 필드가 없다. 문화공공데이터광장 저작권정책은 공공누리 1유형이 아니면 사전 협의를 요구한다 | [확인: https://www.culture.go.kr/data/openapi/openapiView.do?id=598 · https://www.culture.go.kr/data/page/guide/terms.do] |
| 한국문화정보원_문화시설조회서비스 (15138930) | **좌표 보강용으로 쓸 만하다.** 공연장·미술관·박물관 등의 주소·위치 좌표를 준다. 제한 없음, 개발 10,000건/일, 자동승인 | [확인: https://www.data.go.kr/data/15138930/openapi.do] |
| 대구광역시_오페라하우스 공연일정 (15057055) | API 가 아니다. 링크를 따라가면 대구시 RSS 목록이 나오고, `http://www.daeguoperahouse.org/rss.php` 를 가리킨다 | [확인: https://www.data.go.kr/data/15057055/openapi.do · https://www.daegu.go.kr/index.do?menu_id=00000277] |
| 전국공연행사정보표준데이터 (15013106) | **보조 후보.** 정부 예산이 들어간 지자체 공연행사이고 위도·경도 항목이 있다. 226개 기관이 분기마다 갱신한다. API 는 개발 10,000건/일이다. 대구 기관이 데이터를 내는지는 확인하지 못했다 | [확인: https://www.data.go.kr/data/15013106/standard.do] |
| KOPIS 축제목록 (`prffest`) | 공연예술 축제다. 관광공사 축제와 겹칠 수 있다 | [확인: guide v3.7] |
---
## 3. 우리 설계에 붙일 때의 주의점
### 3-1. 좌표
- **KOPIS** [추정]
- 공연 목록·상세에는 좌표가 없다.
- 상세의 `mt10id` 로 공연시설 상세를 불러 `la`/`lo`/`adres` 를 얻는다.
- 시설 정보는 별도 테이블(예: `event_venue`, 키는 `mt10id`)에 캐시한다. 새 시설이 나올 때만 부르고, 한 달에 한 번 갱신한다.
- 공연장은 시설(mt10id) 아래 여러 홀(mt13id, 예: 대구문화예술회관 비슬홀·팔공홀)이 있다. 좌표는 시설 단위다 [확인: guide v3.7, 공통코드 PDF].
- **문화정보원**
- `gpsX` 가 **경도**, `gpsY` 가 **위도**다. 순서를 뒤집지 않는다 [확인: new_ppds_guide.zip].
- 선택 항목이라 비어 있을 수 있다.
- **대구 API**
- 좌표·주소가 없다. 장소 이름과 구만 있다.
- **좌표가 없을 때 순서** [추정]
1. 이미 받은 시설 테이블(KOPIS 시설, 문화시설조회서비스)이나 `place`(관광공사 문화시설)와 **정규화한 이름이 완전히 일치**하는 것에 붙인다. 같은 구 안에서만 찾는다.
2. 그래도 없으면 공공 지오코더로 주소를 좌표로 바꾼다(juso.go.kr 좌표제공 또는 VWorld). 두 서비스의 약관은 아직 보지 않았다(`kakao_place_id.md` 미확인 목록).
3. 그래도 없으면 좌표 없이 저장한다. 지금 정책과 같다. 근처 조회에서만 빠진다(`place-db.md` §12).
- **카카오·네이버 지오코딩 결과는 저장하지 않는다.** 카카오는 "공공기관 API로 변환한 좌표"를 저장하는 것만 허용한다고 답했다(`map_sdk.md`, DevTalk 152228).
- **이름 매칭이 좌표 매칭보다 낫다.** 같은 건물·부지의 다른 시설이 0~10m 안에 있어서, 좌표 거리로는 틀린 연결이 더 가까이 잡혔다는 실측이 있다. 정규화 후 이름이 겹치는 장소는 색인에서 뺐다고 한다 [3rd-party: https://github.com/tour-api-2026/byeori/pull/46].
### 3-2. 스키마·조회 규칙 (`festival_event`, `place-db.md` §2.4·§12)
- **`source_id` 유니크를 바꿔야 한다.** 지금은 관광공사 contentid 단독 유니크다. `source`(tour·kopis·kcisa·dgfca) 컬럼을 더하고 `(source, source_id)` 를 유니크로 한다. `place_sync_run.job` 에도 출처별 작업을 추가한다 [추정].
- **`kind` 에 `exhibition` 을 더한다.** KOPIS 장르(연극·뮤지컬·클래식 …)는 별도 `genre` 컬럼에 둔다 [추정].
- **"60일 넘는 상시 프로그램 제외" 규칙을 종류별로 바꾼다.** 이 규칙을 그대로 두면 전시는 대부분 홈에서 사라진다. 전시는 몇 달씩 하는 경우가 많고, 대구 API 42건 중 7건이 60일을 넘는다 [관측]. KOPIS 오픈런(`openrun=Y`) 공연도 마찬가지다. 전시는 기준을 길게 잡거나 빼야 한다 [추정].
- **필드 매핑**
- `fee_text` ← KOPIS `pcseguidance` / 문화정보원 `price` / 대구 `pay`
- `playtime_text` ← KOPIS `dtguidance`
- `overview` ← KOPIS `sty`, 문화정보원 `contents1`, 대구 `content`(HTML 정리 필요)
- **날짜 형식이 출처마다 다르다:** `2016.05.12` / `20190616` / `2026-11-03`.
- **'이번 주말' 판정 주의.** 지금은 기간이 겹치는지로만 거른다. 그래서 화~금만 하는 공연이 주말 목록에 섞인다. KOPIS `dtguidance`(요일·시각 자유 문구)를 파싱해 토·일 회차가 있는지 보는 것이 이상적이다. 처음에는 표시만 하고 나중에 보강해도 된다 [추정].
- **대구 API 의 `contact` 에는 개인 휴대전화가 섞여 있다.** 응답·검색 색인에 그대로 노출할지 정해야 한다(개인정보 최소화) [관측 + 추정].
- **군위군.** 2023년 대구로 편입됐다 [추정: 기억에 의존, 이번에 원문 확인 안 함]. KOPIS 공통코드의 대구 구군 목록은 달성군(2771)까지만 있다 [확인: 공통코드 PDF]. 그래서 군위 공연이 경북(47)으로 분류될 수 있다 [추정].
### 3-3. 관광공사 행사와 중복 처리
- **겹치는 지점** [추정]
- 관광공사 EV02(공연)·EV01(축제)와 KOPIS 공연·축제목록이 겹칠 수 있다.
- 대구 API 는 사용자 직접 등록이라 KOPIS·문화정보원과 겹칠 수 있다.
- 문화포털에 보이는 대구 공연 목록에는 "[대구] …" 접두어가 붙는다. 다른 출처(KOPIS 계열)에서 들어온 것으로 보이지만 근거는 없다.
- **같은 행사 판정** [추정]
1. 정규화한 제목이 일치한다. `[대구]` 같은 대괄호 접두어, 괄호 주석, 공백, 문장부호를 지운다.
2. 기간이 겹친다.
3. 장소가 같다. 같은 시설 ID, 이름 정규화 일치, 좌표 200m 이내 중 하나다.
4. 셋을 모두 만족할 때만 같은 행사로 본다.
- **지역도 맞아야 한다.** 투어 공연은 도시마다 제목이 같다(예: 3분기 상위 공연의 "싸이흠뻑쇼 [대구]/[광주]/[대전]") [확인: 3분기 보고서]. 제목·기간만으로 묶으면 안 된다.
- **대표 출처 우선순위** [추정]
- 축제: 관광공사
- 공연: KOPIS. 가격·시간·예매처가 구조화되어 있다.
- 전시: 문화정보원 → 대구 API 순
- 나머지 출처는 연결 테이블(예: `festival_event_source`)이나 `merged_into` 로 남긴다. 장소 중복 병합과 같은 방식이다.
- **애매하면 합치지 않는다.** 퍼지 매칭은 쓰지 않는다. 틀린 병합이 더 해롭다(3rd-party 사례도 완전일치만 썼다).
### 3-4. 갱신 주기·호출량
- **KOPIS (매일)**
- **목록**: `signgucode=27`·`47`, `stdate`=오늘, `eddate`=오늘+90일, `rows=100`. 페이지를 끝까지 돈다. 조회 기간을 31일 단위로 나눠 부르는 3rd-party 구현이 있다. 실제 제한인지는 미확인이므로 키를 받은 뒤 90일 한 번 호출로 확인한다 [3rd-party: jaefunk PR #4].
- **상세**: 처음 보는 `mt20id` 이거나 `updatedate` 가 바뀐 것만 부른다. 목록의 `afterdate`(마지막 동기화일 −1일)로 후보를 줄일 수 있다.
- **시설 상세**: 새 `mt10id` 만 부른다.
- **속도**: 초당 10회에서 차단되므로 **초당 2회 이하**로 부른다. 차단·오류가 나면 30초 쉬고 재시도한다 [확인(검색 요약) + 3rd-party].
- **호출 수** [추정]
- 첫 적재(대구+경북): 목록 수십 회, 상세 수백 회, 시설 100~200회
- 이후 하루: 목록 수십 회, 상세 수십 회
- **숨김**: 취소된 공연을 KOPIS 가 어떻게 표시하는지는 미확인이다. 기존 원본 장애 안전장치(`sync/removal-guard.ts`)를 그대로 적용한다.
- **키 노출**: 가이드상 http + 쿼리스트링 키다. 서버 로그·에러 기록에서 키를 가린다. https 로도 되는지는 키 발급 후 확인한다 [추정].
- **문화정보원 (매일)**
- `area2` 를 `sido=대구`(+`경북`), `from`/`to` 로 부른다. `serviceTp=A`(공연/전시)를 기본으로 하고, B(행사/축제)도 받을지 정한다.
- 상세는 새 `seq` 만 부른다. 수정일 필드가 없으므로 주 1회 전체 상세를 다시 받아 비교한다. 대구 물량이 작아 10,000건/일 안에 충분히 든다 [추정].
- **대구 API (매일 1회)**
- 한 번에 전체(2.2MB)가 온다. `end_date ≥ 오늘` 만 남기고 `event_seq` 로 비교한다 [관측].
- **포스터·이미지**
- 권리를 확인하기 전에는 **URL 만 저장하고 원본 주소로 보여 준다.** 서버에 내려받기·리사이즈·썸네일 생성·CDN 재배포는 하지 않는다. `image_license` 는 `unverified` 로 둔다 [추정].
- KOPIS 포스터는 `http://www.kopis.or.kr/…` 이다. iOS ATS 때문에 `https://kopis.or.kr/…` 로 바꿔 저장한다 [3rd-party: Cominggg #90].
- **출처 표시 UI**
- 행사 상세 하단과 앱 정보 화면에 출처를 적는다. KOPIS 는 "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)"이다 [확인(검색 요약)].
- 문화정보원은 "한국문화정보원 문화포털", 대구 API 는 "대구문화예술진흥원"으로 적는다 [추정].
- **KOPIS 키는 서버에서만 쓴다.** 안내문의 "PC에서만 사용 가능"은 앱에 키를 넣어 단말에서 직접 부르는 방식을 막는 뜻으로 읽힌다 [추정]. 지금 설계(서버 동기화)는 문제없다.
---
## 4. 사용자가 해야 할 일
1. **KOPIS 인증키 신청.**
- kopis.or.kr → Open API 메뉴에서 **PC 로** 신청한다. 1인 1키이고 키는 이메일로 온다.
- 3개월 넘게 쓰지 않으면 취소된다는 3rd-party 메모가 있다. 그래서 KOPIS 동기화 구현을 시작하기 직전에 신청한다.
- 받은 키는 `backend/.env.{env}` 에만 둔다(이름 예: `KOPIS_SERVICE_KEY`). 채팅·이슈·커밋에는 적지 않는다.
2. **KOPIS 에 서면 문의.** 공연정보팀 kopis@gokams.or.kr / 02-708-2256. 답을 이 문서에 붙인다.
- (a) 공연·시설 정보를 자체 DB 에 저장·가공해 상업 앱에서 보여 줘도 되는가
- (b) 포스터·소개이미지를 원본 URL 로 앱에 표시해도 되는가. 서버에 저장하거나 리사이즈해도 되는가
- (c) 일일 호출 한도와 조회 기간 제한(31일 여부)
- (d) 키 유효기간과 미사용 취소 기준
- (e) 출처 표시 위치·문구
3. **공공데이터포털 활용신청**(자동승인)
- '한국문화정보원_한눈에보는문화정보조회서비스'(15138937)
- 좌표 보강용 '한국문화정보원_문화시설조회서비스'(15138930)
- 이미 있는 data.go.kr 계정을 쓰면 된다.
4. **(선택) 문화포털팀(02-3153-2843)에 확인.** 회원약관 제17조의 영리행위 금지가 data.go.kr 로 받은 API 데이터에도 적용되는가. `thumbnail`/`imgUrl` 이미지의 이용 조건은 무엇인가.
5. **(선택) 대구시(053-803-3738)에 확인.** 대구 공연·전시 API 의 상업 이용·호출 한도. 포털 표기는 "제한 없음"이다.
6. **포스터 정책 결정.** 2번 답을 받은 뒤 정한다. 원본 URL 로 표시, 표시 안 함, 서버 저장 중 하나다.
7. **키를 받은 뒤 진단 스크립트 실행.** `tools/data-check/festival_region_check.py` 처럼 만든다. 출처별 대구·경북 90일 건수, 좌표 채움 비율(KOPIS `la/lo`, 문화정보원 `gpsX/gpsY`), 관광공사와 겹치는 비율을 잰다. 그다음 `place-db.md` §12 에 반영한다.
---
## 5. 확인하지 못한 것
- **KOPIS 약관·FAQ 원문.** 사이트가 JS 렌더링이라 직접 읽지 못했고 검색 요약으로만 확인했다. 그래서 다음을 모른다.
- 자체 DB 저장·캐시 조항
- 일일 호출 한도
- 승인 소요 시간
- 키 유효기간·미사용 취소(3rd-party 메모는 "1년 / 3개월")
- ~~조회 기간 31일 제한~~ → 2026-11-03 진단으로 해소: 90일을 한 번에 조회할 수 있다(0-1)
- 최신 개발가이드(v5.0으로 알려짐). 이번에 읽은 것은 data.go.kr 의 v3.7 이다. 공통코드만 사이트 최신판을 봤다.
- **포스터 이미지 권리.** 세 출처 모두 명시가 없다. KOPIS 포스터의 저작권자(제작·기획사) 허락 범위도 모른다.
- **KOPIS 목록 응답의 `area` 필드.** v3.7 가이드에는 상세에만 있다. 목록에도 있다는 것은 3rd-party 코드뿐이다.
- **KOPIS `la`/`lo` 채움 비율.** 특히 소극장·비공연장 시설. 취소 공연 표시 방식도 모른다.
- **문화정보원 데이터의 원천 구성.** KOPIS 데이터를 포함하는지 모른다. API 로 받은 대구 실제 건수와 `gpsX/gpsY` 채움 비율도 모른다. 웹 목록 40건만 확인했다.
- **문화포털 회원약관 제17조와 data.go.kr "제한 없음"의 관계.**
- **대구 API 의 별도 약관·호출 한도.** dgfca.or.kr 안에 API 안내 페이지가 있는지 찾지 못했다.
- **전국공연행사정보표준데이터에 대구 기관이 데이터를 내는지.**
- **juso.go.kr 좌표제공·VWorld 지오코더의 저장·상업 이용 조건.**
- **KOPIS 키를 개인사업자·법인 명의로 받을 수 있는지.**
+248
View File
@@ -0,0 +1,248 @@
"""공연·전시 API 진단 — KOPIS(공연)·한국문화정보원 한눈에보는문화정보(전시)를 실제로 불러 구현 전에 확인한다.
확인하는 것
KOPIS: https 동작 · 조회 기간 제한(90일/31일) · 대구(27)·경북(47) 건수 · 목록·상세·공연시설 응답 칸 ·
좌표(la/lo) 채움 · afterdate(최근 수정분) 동작
문화정보: 대구·경북 건수 · 분야(realmName) 분포 · 좌표(gpsX=경도, gpsY=위도) 채움 · 응답 칸 이름
키는 backend/.env.development 의 KOPIS_SERVICE_KEY · PUBLIC_DATA_SERVICE_KEY 에서 읽고 출력하지 않는다.
KOPIS 는 초당 10회에서 차단되므로 0.6초 간격으로 부른다(전체 30회 안팎).
실행(저장소 루트에서): python -I -X utf8 tools/data-check/culture_api_check.py backend/.env.development
"""
import collections
import importlib.util
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import timedelta
from pathlib import Path
def _load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f'{name}.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
c1 = _load('public_data_check') # kst_now 만 쓴다
KOPIS_BASES = ['https://kopis.or.kr/openApi/restful', 'http://www.kopis.or.kr/openApi/restful']
CULTURE = 'https://apis.data.go.kr/B553457/cultureinfo'
REGIONS = {'27': '대구', '47': '경북'}
calls = collections.Counter()
errors = []
secrets = []
def read_key(env_path, name):
for line in Path(env_path).read_text(encoding='utf-8').splitlines():
if line.strip().startswith(name + '='):
value = line.split('=', 1)[1].strip().strip('"').strip("'")
if value and 'change-me' not in value:
secrets.extend([value, urllib.parse.quote(value, safe='')])
return value
return None
def mask(text):
for s in secrets:
if s:
text = text.replace(s, '***')
return text
def get_xml(label, url, params, interval):
calls[label] += 1
query = urllib.parse.urlencode(params)
try:
with urllib.request.urlopen(f'{url}?{query}', timeout=25) as r:
body = r.read().decode('utf-8', errors='replace')
except Exception as e: # 메시지에 URL(키 포함)이 섞일 수 있어 가린다
errors.append(mask(f'{label}: {type(e).__name__} {str(e)[:150]}'))
return None, ''
finally:
time.sleep(interval)
try:
return ET.fromstring(body), body[:300]
except ET.ParseError:
errors.append(mask(f'{label}: XML 아님 — {body[:200]}'))
return None, body[:300]
def leaf(el):
"""자식 요소 중 글자만 있는 것을 {태그: 값} 으로"""
return {ch.tag: (ch.text or '').strip() for ch in el if len(ch) == 0}
def short(v, n=60):
v = (v or '').replace('\n', ' ').strip()
return v if len(v) <= n else v[:n] + '…'
# ---------------- KOPIS ----------------
def kopis_list(base, key, label, extra):
"""공연목록 전체(최대 20쪽). 오류 응답(returncode/errmsg)이면 (None, 메시지)"""
items, page = [], 1
while page <= 20:
root, raw = get_xml(label, f'{base}/pblprfr', {'service': key, 'cpage': page, 'rows': 100, **extra}, 0.6)
if root is None:
return None, raw
dbs = root.findall('db')
if dbs and any(ch.tag in ('returncode', 'errmsg') for ch in dbs[0]):
return None, short(' / '.join(f'{k}={v}' for k, v in leaf(dbs[0]).items()), 200)
items += [leaf(d) for d in dbs]
if len(dbs) < 100:
break
page += 1
return items, ''
def check_kopis(key, today):
print('## KOPIS (공연)')
start = today.strftime('%Y%m%d')
in90 = (today + timedelta(days=90)).strftime('%Y%m%d')
in31 = (today + timedelta(days=31)).strftime('%Y%m%d')
base = None
for b in KOPIS_BASES:
items, msg = kopis_list(b, key, 'KOPIS 주소 확인', {'stdate': start, 'eddate': in31, 'signgucode': '27', 'rows': 1, 'cpage': 1})
if items is not None:
base = b
break
print(f'- {b} 실패: {msg or "응답 없음"}')
if not base:
print('- KOPIS 를 부르지 못했다(키·주소 확인 필요)\n')
return
print(f'- 동작하는 주소: {base}')
d31, msg31 = kopis_list(base, key, 'KOPIS 대구 31일', {'stdate': start, 'eddate': in31, 'signgucode': '27'})
d90, msg90 = kopis_list(base, key, 'KOPIS 대구 90일', {'stdate': start, 'eddate': in90, 'signgucode': '27'})
print(f'- 대구 31일: {len(d31) if d31 is not None else "오류 " + msg31}건')
print(f'- 대구 90일 한 번에: {len(d90) if d90 is not None else "오류 " + msg90}' + ('건' if d90 is not None else ''))
daegu = d90 if d90 is not None else d31 or []
gb, msggb = kopis_list(base, key, 'KOPIS 경북', {'stdate': start, 'eddate': in90 if d90 is not None else in31, 'signgucode': '47'})
print(f'- 경북({"90" if d90 is not None else "31"}일): {len(gb) if gb is not None else "오류 " + msggb}건')
if daegu:
print(f'- 목록 칸: {sorted(daegu[0].keys())}')
for field in ('genrenm', 'prfstate', 'openrun', 'area'):
if field in daegu[0]:
print(f' · {field}: {collections.Counter(i.get(field, "") for i in daegu).most_common(8)}')
long_run = [i for i in daegu if i.get('prfpdfrom') and i.get('prfpdto')
and (int(i['prfpdto'].replace('.', '')) - int(i['prfpdfrom'].replace('.', ''))) > 200]
print(f' · 기간이 대략 2개월 넘는 공연: {len(long_run)}건 / 포스터 있는 공연: {sum(1 for i in daegu if i.get("poster"))}건')
print(' · 예:', ' | '.join(f"{short(i.get('prfnm'), 20)}({i.get('prfpdfrom')}~{i.get('prfpdto')}, {short(i.get('fcltynm'), 14)})" for i in daegu[:5]))
venues, detail_fields = collections.OrderedDict(), collections.Counter()
for it in daegu[:5]:
root, _ = get_xml('KOPIS 공연상세', f"{base}/pblprfr/{it.get('mt20id')}", {'service': key}, 0.6)
db = root.find('db') if root is not None else None
if db is None:
continue
d = leaf(db)
detail_fields.update(k for k, v in d.items() if v)
styurls = len(db.findall('.//styurl'))
relates = [leaf(r) for r in db.findall('.//relate')]
if d.get('mt10id'):
venues[d['mt10id']] = d.get('fcltynm')
print(f" · 상세 {short(d.get('prfnm'), 18)}: 가격={short(d.get('pcseguidance'), 30)} · 시간={short(d.get('dtguidance'), 40)} · "
f"시설ID={d.get('mt10id') or '-'} · 수정일={d.get('updatedate') or '-'} · 소개이미지 {styurls}장 · 예매처 {len(relates)}곳")
print(f' · 상세에 값이 있는 칸(5건 중 몇 건): {dict(detail_fields.most_common())}')
filled = 0
for mt10id, name in list(venues.items())[:5]:
root, _ = get_xml('KOPIS 공연시설상세', f'{base}/prfplc/{mt10id}', {'service': key}, 0.6)
db = root.find('db') if root is not None else None
if db is None:
continue
v = leaf(db)
has = bool(v.get('la') and v.get('lo'))
filled += has
print(f" · 시설 {short(name, 18)}: 위도={v.get('la') or '-'} 경도={v.get('lo') or '-'} 주소={short(v.get('adres'), 28)} · 칸 {sorted(v.keys())[:12]}…")
print(f' · 시설 좌표 채움: {filled}/{min(len(venues), 5)}')
yesterday = (today - timedelta(days=1)).strftime('%Y%m%d')
aft, msgaft = kopis_list(base, key, 'KOPIS afterdate', {'stdate': start, 'eddate': in31, 'signgucode': '27', 'afterdate': yesterday})
print(f'- afterdate={yesterday} (어제 이후 등록·수정, 31일 안): {len(aft) if aft is not None else "오류 " + msgaft}' + ('건' if aft is not None else ''))
print()
# ---------------- 한눈에보는문화정보 ----------------
def culture_list(key, label, op, extra):
items, total, page = [], None, 1
while page <= 10:
root, raw = get_xml(label, f'{CULTURE}/{op}', {'serviceKey': key, 'PageNo': page, 'numOfrows': 100, **extra}, 0.1)
if root is None:
return None, None, raw
code = root.findtext('.//resultCode')
if code and code not in ('00', '0000'):
return None, None, f"{code} {root.findtext('.//resultMsg')}"
if total is None:
t = root.findtext('.//totalCount')
total = int(t) if t and t.isdigit() else None
batch = [leaf(i) for i in root.iter('item')]
items += batch
if len(batch) < 100 or (total is not None and len(items) >= total):
break
page += 1
return items, total, ''
def check_culture(key, today):
print('## 한눈에보는문화정보 (전시·공연)')
start = today.strftime('%Y%m%d')
end = (today + timedelta(days=90)).strftime('%Y%m%d')
for sido in ('대구', '경북'):
items, total, msg = culture_list(key, f'문화정보 {sido}', 'area2', {'sido': sido, 'from': start, 'to': end})
if items is None:
print(f'- {sido}: 오류 {msg}')
continue
print(f'- {sido} 90일: {len(items)}건 (totalCount {total})')
if not items:
continue
print(f' · 목록 칸: {sorted(items[0].keys())}')
realm = collections.Counter(i.get('realmName', '') for i in items)
print(f' · 분야: {realm.most_common(10)}')
gps = sum(1 for i in items if i.get('gpsX') and i.get('gpsY'))
thumb = sum(1 for i in items if i.get('thumbnail'))
print(f' · 좌표 채움 {gps}/{len(items)} · 썸네일 {thumb}/{len(items)}')
print(' · 예:', ' | '.join(f"{short(i.get('title'), 18)}({i.get('startDate')}~{i.get('endDate')}, {i.get('realmName')}, {short(i.get('place'), 12)})" for i in items[:5]))
if sido == '대구':
seq = items[0].get('seq')
root, _ = get_xml('문화정보 상세', f'{CULTURE}/detail2', {'serviceKey': key, 'seq': seq}, 0.1)
item = root.find('.//item') if root is not None else None
if item is not None:
d = leaf(item)
print(f" · 상세 칸: {sorted(d.keys())}")
print(f" · 상세 예: 가격={short(d.get('price'), 30)} · 주소={short(d.get('placeAddr'), 30)} · 이미지={'있음' if d.get('imgUrl') else '없음'} · 전화={'있음' if d.get('phone') else '없음'}")
print()
def main(env_path):
today = c1.kst_now()
kopis_key = read_key(env_path, 'KOPIS_SERVICE_KEY')
public_key = read_key(env_path, 'PUBLIC_DATA_SERVICE_KEY')
print(f'# 공연·전시 API 진단 — {today.strftime("%Y-%m-%d")} 기준\n')
if kopis_key:
check_kopis(kopis_key, today)
else:
print('## KOPIS — KOPIS_SERVICE_KEY 가 backend/.env.development 에 없어 건너뜀\n')
if public_key:
check_culture(public_key, today)
else:
print('## 문화정보 — PUBLIC_DATA_SERVICE_KEY 가 없어 건너뜀\n')
print(f'호출 수: {dict(calls)}')
if errors:
print(f'오류 {len(errors)}건(앞 8건):')
for e in errors[:8]:
print(' -', e)
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'backend/.env.development')
+103
View File
@@ -0,0 +1,103 @@
"""축제 지역 조회 진단 — 대구 축제·행사가 90일 동안 1건만 잡힌 이유가 데이터 부족인지 조회 조건 때문인지 본다.
세 가지로 불러 비교한다(searchFestival2, 5~6회 호출).
A. 지역(대구 27) + 시작일 + 종료일 ← 동기화가 쓰는 조건
B. 지역(대구 27) + 시작일만 ← 종료일 조건의 영향
C. 전국 + 시작일 + 종료일 ← 지역 코드가 비었거나 다르게 들어간 대구 행사가 있는지(주소로 확인)
키는 출력하지 않는다(public_data_check 의 call 이 가린다).
실행(저장소 루트에서): python -I -X utf8 tools/data-check/festival_region_check.py backend/.env.development
"""
import collections
import importlib.util
import re
import sys
from datetime import timedelta
from pathlib import Path
def _load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f'{name}.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
c1 = _load('public_data_check')
DAEGU, GYEONGBUK = '27', '47'
LOOKAHEAD_DAYS = 90
def fetch(key, label, params):
out, page = [], 1
while page <= 10:
d = c1.call(key, label, f'{c1.TOUR}/searchFestival2',
{**c1.COMMON, **params, 'numOfRows': 500, 'pageNo': page, 'arrange': 'A'})
its = c1.items_of(d)
out += its
if not its or len(out) >= (c1.total_of(d) or 0):
break
page += 1
return out
def line(it):
return (f"{it.get('title')} ({it.get('eventstartdate')}–{it.get('eventenddate')}) "
f"지역코드={it.get('lDongRegnCd') or '(없음)'} 분류={it.get('lclsSystm2') or '-'} 주소={str(it.get('addr1') or '')[:24]}")
def main(env_path):
key = c1.load_key(env_path)
today = c1.kst_now()
start = today.strftime('%Y%m%d')
end = (today + timedelta(days=LOOKAHEAD_DAYS)).strftime('%Y%m%d')
print(f'# 축제 지역 조회 진단 — 기간 {start} ~ {end}\n')
a = fetch(key, '축제 A 대구+종료일', {'eventStartDate': start, 'eventEndDate': end, 'lDongRegnCd': DAEGU})
b = fetch(key, '축제 B 대구', {'eventStartDate': start, 'lDongRegnCd': DAEGU})
c = fetch(key, '축제 C 전국+종료일', {'eventStartDate': start, 'eventEndDate': end})
print(f'A. 대구 + 종료일 조건: {len(a)}건')
print(f'B. 대구 (종료일 조건 없음): {len(b)}건')
print(f'C. 전국 + 종료일 조건: {len(c)}건')
by_code = collections.Counter(str(it.get('lDongRegnCd') or '(없음)') for it in c)
print('\nC 의 시·도 코드 분포(상위 10):', by_code.most_common(10))
no_code = [it for it in c if not it.get('lDongRegnCd')]
# '해운대구' 처럼 '대구'가 글자로만 들어간 주소를 거르려고 시·도 이름으로 시작하는지 본다
daegu_by_addr = [it for it in c if re.match(r'\s*대구(광역시)?\s', str(it.get('addr1') or ''))]
daegu_by_code = [it for it in c if str(it.get('lDongRegnCd')) == DAEGU]
gb = [it for it in c if str(it.get('lDongRegnCd')) == GYEONGBUK]
print(f'C 중 지역 코드가 빈 행사: {len(no_code)}건')
print(f'C 중 대구 — 코드 기준 {len(daegu_by_code)}건 · 주소 기준 {len(daegu_by_addr)}건')
print(f'C 중 경북(47): {len(gb)}건')
long_run = [it for it in c if it.get('eventstartdate') and it.get('eventenddate')
and (int(str(it['eventenddate'])[:8]) - int(str(it['eventstartdate'])[:8])) > 200]
print(f'C 중 기간이 매우 긴 행사(대략 2개월 이상): {len(long_run)}건')
print('\n대구로 보이는 행사(주소 또는 코드):')
seen = set()
for it in daegu_by_addr + daegu_by_code + [i for i in b if i not in daegu_by_code]:
cid = it.get('contentid')
if cid in seen:
continue
seen.add(cid)
print(' -', line(it))
if no_code:
print('\n지역 코드가 빈 행사 예(5):')
for it in no_code[:5]:
print(' -', line(it))
print('\n경북 행사 예(8):')
for it in gb[:8]:
print(' -', line(it))
print(f'\n호출 수: {c1.calls}')
if c1.errors:
print(f'오류 {len(c1.errors)}건:')
for e in c1.errors[:5]:
print(' -', e)
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'backend/.env.development')