feat(backend): 장소·축제·공연·전시 데이터 기반(장소 DB·동기화·조회 API) #1

Merged
ttipo merged 30 commits from feat/place-db into main 2026-11-04 13:25:51 +09:00
5 changed files with 195 additions and 8 deletions
Showing only changes of commit e383c2e242 - Show all commits
@@ -0,0 +1,81 @@
import {
DedupeCandidate,
dropSameVenueDuplicates,
isSameVenueDuplicate,
longestCommonRun,
} from './event-dedupe';
const base: DedupeCandidate = {
sourceId: '100',
name: '문준호 전 - 남겨진 것들을 위한 풍경',
eventPlace: '갤러리소헌&소헌컨템포러리',
startDate: '2026-10-20',
endDate: '2026-11-13',
};
describe('longestCommonRun', () => {
it('연속으로 겹치는 가장 긴 부분의 길이', () => {
expect(longestCommonRun('abcdef', 'zcdez')).toBe(3);
expect(longestCommonRun('', 'abc')).toBe(0);
});
});
describe('isSameVenueDuplicate', () => {
it('같은 장소·기간에 제목이 6글자 이상 겹치면 같은 전시(2026-11-03 실제 사례)', () => {
expect(
isSameVenueDuplicate(base, {
...base,
sourceId: '200',
name: '문준호 《남겨진 것들을 위한 풍경》',
}),
).toBe(true);
});
it('같은 장소·기간이어도 제목이 다르면 다른 전시(여러 전시실이 같은 날 여는 곳)', () => {
expect(
isSameVenueDuplicate(base, {
...base,
sourceId: '200',
name: '유리상자 시리즈展 우덕하 REVERSE FLOW',
}),
).toBe(false);
});
it('기간이 하루라도 다르거나 장소가 다르거나 없으면 합치지 않는다', () => {
const same = {
...base,
sourceId: '200',
name: '문준호 《남겨진 것들을 위한 풍경》',
};
expect(isSameVenueDuplicate(base, { ...same, endDate: '2026-11-14' })).toBe(
false,
);
expect(
isSameVenueDuplicate(base, { ...same, eventPlace: '봉산문화회관' }),
).toBe(false);
expect(
isSameVenueDuplicate(
{ ...base, eventPlace: null },
{ ...same, eventPlace: null },
),
).toBe(false);
});
});
describe('dropSameVenueDuplicates', () => {
it('먼저 등록된 것(id 숫자가 작은 것)만 남긴다', () => {
const later = {
...base,
sourceId: '1000',
name: '문준호 《남겨진 것들을 위한 풍경》',
};
const other = {
...base,
sourceId: '50',
name: '봉산 청년작가공모 틈 - 서승희',
};
const { kept, dropped } = dropSameVenueDuplicates([later, base, other]);
expect(kept.map((r) => r.sourceId)).toEqual(['50', '100']);
expect(dropped.map((r) => r.sourceId)).toEqual(['1000']);
});
});
@@ -0,0 +1,74 @@
import { eventNameKey } from './event-name';
/**
* 같은 출처 안에 제목만 다르게 두 번 등록된 행사 찾기 (순수 함수).
* 2026-11-03 한국문화정보원: "문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》"
* (같은 갤러리 · 같은 기간)이 따로 있었다.
*
* 장소·기간만 보면 안 된다 — 대구문화예술회관처럼 여러 전시실에서 같은 날 여러 전시를 여는 곳이 있다.
* 그래서 제목 비교 키가 연속으로 이 글자 수 이상 겹칠 때만 같은 행사로 본다.
*/
export const SAME_EVENT_MIN_COMMON_CHARS = 6;
export interface DedupeCandidate {
sourceId: string;
name: string;
eventPlace: string | null;
startDate: string;
endDate: string;
}
/** 두 문자열에 연속으로 겹치는 가장 긴 부분의 길이 */
export function longestCommonRun(a: string, b: string): number {
let best = 0;
let prev = new Array<number>(b.length + 1).fill(0);
for (let i = 1; i <= a.length; i += 1) {
const cur = new Array<number>(b.length + 1).fill(0);
for (let j = 1; j <= b.length; j += 1) {
if (a[i - 1] === b[j - 1]) {
cur[j] = prev[j - 1] + 1;
if (cur[j] > best) best = cur[j];
}
}
prev = cur;
}
return best;
}
/** 같은 장소(이름 비교 키) · 같은 시작·종료일 · 제목이 6글자 이상 겹치면 같은 행사 */
export function isSameVenueDuplicate(
a: DedupeCandidate,
b: DedupeCandidate,
): boolean {
const placeA = a.eventPlace ? eventNameKey(a.eventPlace) : '';
const placeB = b.eventPlace ? eventNameKey(b.eventPlace) : '';
if (!placeA || placeA !== placeB) return false;
if (a.startDate !== b.startDate || a.endDate !== b.endDate) return false;
return (
longestCommonRun(eventNameKey(a.name), eventNameKey(b.name)) >=
SAME_EVENT_MIN_COMMON_CHARS
);
}
/**
* 같은 행사로 보이는 것 중 먼저 등록된 것(원본 id 숫자가 작은 것)만 남긴다.
* 숫자가 아닌 id 는 문자열 순서로 비교한다.
*/
export function dropSameVenueDuplicates<T extends DedupeCandidate>(
rows: readonly T[],
): { kept: T[]; dropped: T[] } {
const order = (id: string) => (/^\d+$/.test(id) ? Number(id) : Number.NaN);
const sorted = [...rows].sort((x, y) => {
const nx = order(x.sourceId);
const ny = order(y.sourceId);
if (!Number.isNaN(nx) && !Number.isNaN(ny)) return nx - ny;
return x.sourceId.localeCompare(y.sourceId);
});
const kept: T[] = [];
const dropped: T[] = [];
for (const row of sorted) {
if (kept.some((k) => isSameVenueDuplicate(k, row))) dropped.push(row);
else kept.push(row);
}
return { kept, dropped };
}
+29 -6
View File
@@ -6,6 +6,7 @@ import { sidoCodeFromAddress } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { dropSameVenueDuplicates } from './event-dedupe';
import { eventNameKey } from './event-name';
import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync';
import { KCISA_OPERATIONS } from './kcisa-api.client';
@@ -45,7 +46,11 @@ const LIST_OVERWRITE_PROPS = [
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = KcisaSkipReason | 'other_region' | 'duplicate_other_source';
type SkipReason =
| KcisaSkipReason
| 'other_region'
| 'duplicate_other_source'
| 'duplicate_same_source';
export interface KcisaSyncResult {
regionCode: string;
@@ -64,6 +69,15 @@ export interface KcisaSyncResult {
errors: string[];
}
/** 목록에서 만든 행 — 중복 판단에 이름·장소·기간이 필요하다 */
type KcisaRow = Partial<FestivalEvent> & {
sourceId: string;
name: string;
eventPlace: string | null;
startDate: string;
endDate: string;
};
const toYmd = (iso: string) => iso.replace(/-/g, '');
const errorText = (error: unknown) =>
@@ -189,7 +203,7 @@ export class KcisaSync {
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const isDuplicate = await this.otherSourceMatcher(regionCode, window);
const rows = new Map<string, Partial<FestivalEvent>>();
const listed = new Map<string, KcisaRow>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let res: KcisaPage;
@@ -227,7 +241,7 @@ export class KcisaSync {
this.skip(result, 'duplicate_other_source');
continue;
}
rows.set(columns.sourceId, {
listed.set(columns.sourceId, {
...columns,
source: SOURCE,
kind: 'exhibition',
@@ -236,8 +250,6 @@ export class KcisaSync {
lastSeenAt: runStart,
updatedAt: runStart,
});
if (bySourceId.has(columns.sourceId)) result.updated += 1;
else result.inserted += 1;
}
if (res.items.length < PAGE_SIZE || result.fetched >= result.totalCount) {
complete = true;
@@ -245,7 +257,18 @@ export class KcisaSync {
}
}
await this.upsert([...rows.values()]);
// 같은 전시가 제목만 다르게 두 번 등록된 경우 먼저 등록된 것만 남긴다(event-dedupe.ts)
const { kept, dropped } = dropSameVenueDuplicates([...listed.values()]);
for (let i = 0; i < dropped.length; i += 1) {
this.skip(result, 'duplicate_same_source');
}
const rows = new Map(kept.map((r) => [r.sourceId, r]));
for (const id of rows.keys()) {
if (bySourceId.has(id)) result.updated += 1;
else result.inserted += 1;
}
await this.upsert(kept);
if (!complete) return;
// 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜)
+8 -2
View File
@@ -116,12 +116,14 @@ describe('한국문화정보원 전시 동기화 (e2e)', () => {
await moduleRef.close();
});
it('첫 실행: 전시만 넣고, 다른 출처에 있는 행사는 건너뛰고, 상세를 받는다(이미지는 넣지 않음)', async () => {
it('첫 실행: 전시만 넣고, 다른 출처·같은 출처 중복은 건너뛰고, 상세를 받는다(이미지는 넣지 않음)', async () => {
fakeList = [
item('99000001'),
item('99000002', { startDate: ymd(5), endDate: ymd(60) }),
item('99000003', { realmName: '음악/콘서트' }),
item('99000004', { title: '시험 비엔날레' }),
// 99000001 과 같은 장소·기간, 제목만 다르게 다시 등록된 전시
item('99000005', { title: '시험 전시 《99000001》 특별전' }),
];
const result = await sync.run({ regionCode: REGION });
@@ -130,7 +132,11 @@ describe('한국문화정보원 전시 동기화 (e2e)', () => {
inserted: 2,
detailed: 2,
detailRemaining: 0,
skipped: { not_exhibition: 1, duplicate_other_source: 1 },
skipped: {
not_exhibition: 1,
duplicate_other_source: 1,
duplicate_same_source: 1,
},
calls: { area2: 1, detail2: 2 },
});
expect(await event('99000001')).toMatchObject({
+3
View File
@@ -427,6 +427,9 @@ node dist/commands/place-sync.js tour-kor
- 순서(`sync/kcisa.sync.ts`)
1. **목록** `area2`(sido=대구·경북, from·to). **분야가 '전시'인 것만** 넣는다 — 공연은 KOPIS 와 겹친다.
관광공사·KOPIS 에 이름 비교 키(`event-name.ts`, 괄호·문장부호·공백 제거)가 같고 기간이 겹치는 행사가 있으면 넣지 않는다.
같은 출처에 제목만 다르게 두 번 등록된 전시(2026-11-03: "문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》")는
같은 장소 · 같은 시작·종료일 · 제목 비교 키가 연속 6글자 이상 겹칠 때 하나로 보고 먼저 등록된 것(seq 가 작은 것)만 남긴다(`event-dedupe.ts`).
장소·기간만으로는 합치지 않는다 — 여러 전시실에서 같은 날 여러 전시를 여는 곳이 있다.
목록을 끝까지 받았으면 받은 기간 안에서 이번에 넣지 않은 전시를 숨긴다(원본 장애 판단은 §4.1).
2. **상세** `detail2` — 새 전시와 상세를 받은 지 7일 지난 전시만(원본에 수정일이 없다). 요금 `price`, 소개 `contents1`, 전화, 주소 `placeAddr`, 안내 링크 `url`.
- 좌표는 목록에 있다(`gpsY` 위도 · `gpsX` 경도). 지역의 시·도 영역 밖이면 버린다.