From 90d41f0e6d6391469226bb3c91924eddc3fca319 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 5 Nov 2026 13:37:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20KOPIS=20=EC=97=90=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=93=B1=EB=A1=9D=EB=90=9C=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B3=B5=EC=97=B0=EC=9D=84=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=ED=95=A9=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다. 그래서 같은 공연장·같은 날 같은 공연이 두 번 보였다(2026-11-04 회귀 점검: 고령 남진 콘서트). - 제목 앞의 "[일정변경]" 은 뗀다(화면에는 바뀐 날짜가 이미 보인다) - 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남기고 나머지는 숨긴다 문화포털 전시에 쓰는 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다. 처음 적용했을 때 음악제 이름을 앞에 붙인 같은 날 다른 공연("포항국제음악제, …" 두 공연)을 합쳐 버려서, 제목이 같을 때만 합치게 했다. Co-Authored-By: Claude Opus 5.5 --- .../modules/place/sync/event-dedupe.spec.ts | 37 ++++++++++++++++ .../src/modules/place/sync/event-dedupe.ts | 26 ++++++++++- .../modules/place/sync/kopis-mapper.spec.ts | 19 ++++++++ .../src/modules/place/sync/kopis-mapper.ts | 17 ++++++- backend/src/modules/place/sync/kopis.sync.ts | 44 +++++++++++++++---- backend/test/kopis-sync.e2e-spec.ts | 36 +++++++++++++++ docs/design/place-db.md | 7 ++- 7 files changed, 173 insertions(+), 13 deletions(-) diff --git a/backend/src/modules/place/sync/event-dedupe.spec.ts b/backend/src/modules/place/sync/event-dedupe.spec.ts index 04a0d93..518d558 100644 --- a/backend/src/modules/place/sync/event-dedupe.spec.ts +++ b/backend/src/modules/place/sync/event-dedupe.spec.ts @@ -2,6 +2,7 @@ import { DedupeCandidate, dropSameVenueDuplicates, isSameVenueDuplicate, + isSameVenueSameName, longestCommonRun, } from './event-dedupe'; @@ -79,3 +80,39 @@ describe('dropSameVenueDuplicates', () => { expect(dropped.map((r) => r.sourceId)).toEqual(['1000']); }); }); + +describe('isSameVenueSameName', () => { + const show: DedupeCandidate = { + sourceId: 'PF300322', + name: '포항국제음악제, 포항시립교향악단 with 김영욱 & 율리안 슈테켈', + eventPlace: '포항문화예술회관', + startDate: '2026-11-10', + endDate: '2026-11-10', + }; + + it('같은 장소·기간·제목이면 같은 공연(일정변경 알림을 뗀 재등록)', () => { + expect( + isSameVenueSameName( + { ...show, name: '데뷔 60주년 기념공연, 남진 전국투어 콘서트' }, + { + ...show, + sourceId: 'PF298257', + name: '데뷔 60주년 기념공연, 남진 전국투어 콘서트', + }, + ), + ).toBe(true); + }); + + it('음악제 이름만 같은 같은 날 다른 공연은 합치지 않는다(2026-11-05 포항국제음악제)', () => { + const other = { + ...show, + sourceId: 'PF302934', + name: '포항국제음악제, 아티스트 포항 Ⅱ. 문성우', + }; + expect(isSameVenueDuplicate(show, other)).toBe(true); // 전시용 기준이면 합쳐진다 + expect(isSameVenueSameName(show, other)).toBe(false); + expect( + dropSameVenueDuplicates([show, other], isSameVenueSameName).dropped, + ).toEqual([]); + }); +}); diff --git a/backend/src/modules/place/sync/event-dedupe.ts b/backend/src/modules/place/sync/event-dedupe.ts index be32a6f..14a59ac 100644 --- a/backend/src/modules/place/sync/event-dedupe.ts +++ b/backend/src/modules/place/sync/event-dedupe.ts @@ -50,12 +50,34 @@ export function isSameVenueDuplicate( ); } +/** + * 같은 장소(이름 비교 키) · 같은 시작·종료일 · 제목 비교 키까지 같으면 같은 행사 — KOPIS 용 엄격한 기준. + * 공연은 음악제·시리즈 이름을 제목 앞에 붙여 같은 날 같은 공연장의 다른 공연도 6글자 넘게 겹친다 + * (2026-11-05 "포항국제음악제, 아티스트 포항 Ⅱ. 문성우" / "포항국제음악제, 포항시립교향악단 with …"). + * KOPIS 의 실제 중복("[일정변경]" 재등록)은 알림을 떼면 제목이 같다. + */ +export function isSameVenueSameName( + 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; + const nameA = eventNameKey(a.name); + return nameA !== '' && nameA === eventNameKey(b.name); +} + /** * 같은 행사로 보이는 것 중 먼저 등록된 것(원본 id 숫자가 작은 것)만 남긴다. - * 숫자가 아닌 id 는 문자열 순서로 비교한다. + * 숫자가 아닌 id 는 문자열 순서로 비교한다. 같은 행사 판단은 [isSame](기본은 제목 6글자 겹침)으로 한다. */ export function dropSameVenueDuplicates( rows: readonly T[], + isSame: ( + a: DedupeCandidate, + b: DedupeCandidate, + ) => boolean = isSameVenueDuplicate, ): { kept: T[]; dropped: T[] } { const order = (id: string) => (/^\d+$/.test(id) ? Number(id) : Number.NaN); const sorted = [...rows].sort((x, y) => { @@ -67,7 +89,7 @@ export function dropSameVenueDuplicates( const kept: T[] = []; const dropped: T[] = []; for (const row of sorted) { - if (kept.some((k) => isSameVenueDuplicate(k, row))) dropped.push(row); + if (kept.some((k) => isSame(k, row))) dropped.push(row); else kept.push(row); } return { kept, dropped }; diff --git a/backend/src/modules/place/sync/kopis-mapper.spec.ts b/backend/src/modules/place/sync/kopis-mapper.spec.ts index 4c5f8e8..b5f0562 100644 --- a/backend/src/modules/place/sync/kopis-mapper.spec.ts +++ b/backend/src/modules/place/sync/kopis-mapper.spec.ts @@ -1,4 +1,5 @@ import { + cleanKopisTitle, kopisDate, kopisDetail, kopisImageUrl, @@ -238,3 +239,21 @@ describe('stripRegionTag', () => { expect(stripRegionTag('[대구]')).toBe('[대구]'); }); }); + +describe('cleanKopisTitle', () => { + it('제목 앞의 [일정변경] 알림과 끝의 지역 꼬리표를 함께 뗀다(2026-11-04 실제 제목)', () => { + expect( + cleanKopisTitle( + '[일정변경] 데뷔 60주년 기념공연, 남진 전국투어 콘서트 [대구 북구]', + ), + ).toBe('데뷔 60주년 기념공연, 남진 전국투어 콘서트'); + expect(cleanKopisTitle('[일정 변경] 굿프렌즈')).toBe('굿프렌즈'); + }); + + it('다른 머리 꼬리표와 알림뿐인 제목은 그대로 둔다', () => { + expect(cleanKopisTitle('[패키지] 더 피아니스트')).toBe( + '[패키지] 더 피아니스트', + ); + expect(cleanKopisTitle('[일정변경]')).toBe('[일정변경]'); + }); +}); diff --git a/backend/src/modules/place/sync/kopis-mapper.ts b/backend/src/modules/place/sync/kopis-mapper.ts index b369e31..610ea2e 100644 --- a/backend/src/modules/place/sync/kopis-mapper.ts +++ b/backend/src/modules/place/sync/kopis-mapper.ts @@ -119,13 +119,26 @@ export function stripRegionTag(name: string): string { return [name.slice(0, m.index), suffix].filter(Boolean).join(' ').trim(); } +/** 제목 앞의 일정 변경 알림 — "[일정변경] 데뷔 60주년 기념공연 …" */ +const SCHEDULE_NOTICE_RE = /^\s*\[\s*일정\s*변경\s*\]\s*/; + +/** + * 제목을 정리한다 — 앞의 일정 변경 알림과 끝의 지역 꼬리표를 뗀다. + * KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다(2026-11-04 대구 1쌍). + * 화면에는 바뀐 날짜가 이미 보이므로 알림은 떼고, 같은 공연 두 건은 중복 제거(event-dedupe)가 하나로 줄인다. + */ +export function cleanKopisTitle(title: string): string { + const withoutNotice = title.replace(SCHEDULE_NOTICE_RE, ''); + return stripRegionTag(withoutNotice === '' ? title : withoutNotice); +} + /** 목록 항목 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; + const name = title ? cleanKopisTitle(title) : null; if (!sourceId || !KOPIS_ID_RE.test(sourceId) || !name) { return { skip: 'no_id' }; } @@ -195,7 +208,7 @@ export function kopisDetail(db: KopisItem): KopisDetail { startDate !== null && endDate !== null && endDate >= startDate; const title = htmlToText(db.prfnm); return { - name: title ? stripRegionTag(title) : null, + name: title ? cleanKopisTitle(title) : null, startDate: validPeriod ? startDate : null, endDate: validPeriod ? endDate : null, genre: str(db, 'genrenm'), diff --git a/backend/src/modules/place/sync/kopis.sync.ts b/backend/src/modules/place/sync/kopis.sync.ts index 4e18c94..632d960 100644 --- a/backend/src/modules/place/sync/kopis.sync.ts +++ b/backend/src/modules/place/sync/kopis.sync.ts @@ -7,6 +7,7 @@ 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 { dropSameVenueDuplicates, isSameVenueSameName } from './event-dedupe'; import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync'; import { KOPIS_OPERATIONS } from './kopis-api.client'; import type { KopisApi, KopisItem } from './kopis-api.client'; @@ -49,7 +50,20 @@ const LIST_OVERWRITE_PROPS = [ 'updatedAt', ] as const satisfies readonly (keyof FestivalEvent)[]; -type SkipReason = KopisSkipReason | 'other_region' | 'duplicate'; +type SkipReason = + | KopisSkipReason + | 'other_region' + | 'duplicate' + | 'duplicate_same_source'; + +/** 목록에서 만든 행 — 중복 판단에 이름·장소·기간이 필요하다 */ +type KopisRow = Partial & { + sourceId: string; + name: string; + eventPlace: string | null; + startDate: string; + endDate: string; +}; export interface KopisSyncResult { regionCode: string; @@ -217,7 +231,7 @@ export class KopisSync { }); const bySourceId = new Map(existing.map((e) => [e.sourceId, e])); - const rows = new Map>(); + const listed = new Map(); let complete = false; for (let page = 1; page <= MAX_PAGES; page += 1) { let items: KopisItem[]; @@ -251,11 +265,11 @@ export class KopisSync { this.skip(result, 'other_region'); continue; } - if (rows.has(mapped.sourceId)) { + if (listed.has(mapped.sourceId)) { this.skip(result, 'duplicate'); // 쪽 경계에서 같은 공연이 두 번 올 수 있다 continue; } - rows.set(mapped.sourceId, { + listed.set(mapped.sourceId, { ...columns, source: SOURCE, kind: 'performance', @@ -264,8 +278,6 @@ export class KopisSync { lastSeenAt: runStart, updatedAt: runStart, }); - if (bySourceId.has(mapped.sourceId)) result.updated += 1; - else result.inserted += 1; } if (items.length < PAGE_SIZE) { complete = true; @@ -273,10 +285,26 @@ export class KopisSync { } } - await this.upsert([...rows.values()]); + // 일정이 바뀐 공연을 새 id 로 다시 등록하면("[일정변경] …") 같은 공연장·같은 기간에 두 건이 남는다. + // 먼저 등록된 것만 남기고 나머지는 넣지 않아 아래에서 숨긴다(event-dedupe.ts, 2026-11-04 회귀 점검). + // 제목이 같을 때만 합친다 — 음악제 이름을 앞에 붙인 같은 날 다른 공연을 합치지 않게(isSameVenueSameName) + const { kept, dropped } = dropSameVenueDuplicates( + [...listed.values()], + isSameVenueSameName, + ); + 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; - // 받은 기간과 겹치는데 이번 목록에 없는 공연은 숨긴다(취소·삭제) + // 받은 기간과 겹치는데 이번 목록에 없는 공연은 숨긴다(취소·삭제·같은 공연 중복) const inWindowBefore = existing.filter( (e) => e.status === 'active' && diff --git a/backend/test/kopis-sync.e2e-spec.ts b/backend/test/kopis-sync.e2e-spec.ts index a7f1b12..40d892b 100644 --- a/backend/test/kopis-sync.e2e-spec.ts +++ b/backend/test/kopis-sync.e2e-spec.ts @@ -232,4 +232,40 @@ describe('KOPIS 공연 동기화 (e2e)', () => { expect((await event('PFE2E2')).status).toBe('hidden'); expect((await event('PFE2E1')).status).toBe('active'); }); + + // 2026-11-04 회귀 점검: "[일정변경] 데뷔 60주년 기념공연 …" 이 원래 공연과 함께 두 번 보였다 + it('일정이 바뀌어 새 id 로 다시 등록된 같은 공연은 먼저 등록된 것만 남기고 나머지는 숨긴다', async () => { + const renamed = '[일정변경] 시험 공연 PFE2E1'; + // 이전 실행에서 이미 들어온 중복(중복 제거 전에 저장된 데이터) + await ds.getRepository(FestivalEvent).save({ + source: 'kopis', + sourceId: 'PFE2E5', + kind: 'performance', + name: renamed, + startDate: kstDateString(new Date(), 0), + endDate: kstDateString(new Date(), 5), + eventPlace: '시험극장', + ldongRegnCd: REGION, + status: 'active', + }); + fake.list = [ + listItem('PFE2E1'), + listItem('PFE2E5', { prfnm: renamed }), + // 같은 날 같은 공연장의 다른 공연 — 앞의 행사 이름만 같으면 합치지 않는다(포항국제음악제 사례) + listItem('PFE2E6', { prfnm: '시험 공연 PFE2E1, 아티스트 시리즈 Ⅱ' }), + ]; + fake.changed = []; + + const result = await sync.run({ regionCode: REGION }); + + expect(result).toMatchObject({ + updated: 1, + inserted: 1, + hidden: 1, + skipped: { duplicate_same_source: 1 }, + }); + expect((await event('PFE2E1')).status).toBe('active'); + expect((await event('PFE2E5')).status).toBe('hidden'); + expect((await event('PFE2E6')).status).toBe('active'); + }); }); diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 1ce1b7c..c12d10a 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -427,12 +427,17 @@ node dist/commands/place-sync.js tour-kor 4. 시설의 좌표·주소·전화를 공연에 복사한다. 조회 API 가 공연 좌표로 거리를 계산하기 때문이다. 시설 좌표를 고치면 다음 실행에 공연도 바뀐다. - 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]", "[대구 북구]", "[안동] (11월)")를 떼어 저장한다. 2026-11-03 244건 중 203건에 붙어 있었다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)"·"(11.10)" 같은 메모는 남긴다(`kopis-mapper.ts` `stripRegionTag`). +- 일정이 바뀐 공연은 제목에 "[일정변경]" 을 붙인 채 새 id 로 다시 등록된다(2026-11-04 대구 1쌍 — 같은 공연장·같은 날 두 건). + 제목 앞의 "[일정변경]" 은 떼고(`cleanKopisTitle`), 같은 공연장·같은 기간·**같은 제목**이면 먼저 등록된 것만 남긴다 + (`event-dedupe.ts` `isSameVenueSameName`). 나머지는 넣지 않아 숨겨진다. + 문화포털 전시의 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다 — 음악제 이름을 앞에 붙인 같은 날 다른 공연을 합쳤다 + (2026-11-05 "포항국제음악제, 아티스트 포항 Ⅱ. 문성우" / "포항국제음악제, 포항시립교향악단 with …"). - 포스터는 `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` 요일 해석은 나중에).