diff --git a/backend/src/modules/place/sync/event-dedupe.spec.ts b/backend/src/modules/place/sync/event-dedupe.spec.ts new file mode 100644 index 0000000..04a0d93 --- /dev/null +++ b/backend/src/modules/place/sync/event-dedupe.spec.ts @@ -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']); + }); +}); diff --git a/backend/src/modules/place/sync/event-dedupe.ts b/backend/src/modules/place/sync/event-dedupe.ts new file mode 100644 index 0000000..be32a6f --- /dev/null +++ b/backend/src/modules/place/sync/event-dedupe.ts @@ -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(b.length + 1).fill(0); + for (let i = 1; i <= a.length; i += 1) { + const cur = new Array(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( + 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 }; +} diff --git a/backend/src/modules/place/sync/kcisa.sync.ts b/backend/src/modules/place/sync/kcisa.sync.ts index 72c5857..fe79893 100644 --- a/backend/src/modules/place/sync/kcisa.sync.ts +++ b/backend/src/modules/place/sync/kcisa.sync.ts @@ -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 & { + 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>(); + const listed = new Map(); 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; // 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜) diff --git a/backend/test/kcisa-sync.e2e-spec.ts b/backend/test/kcisa-sync.e2e-spec.ts index e2ee8e0..3d1ea8a 100644 --- a/backend/test/kcisa-sync.e2e-spec.ts +++ b/backend/test/kcisa-sync.e2e-spec.ts @@ -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({ diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 9514d01..b5bf835 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -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` 경도). 지역의 시·도 영역 밖이면 버린다.