generated from ttipo/project-base
fix(backend): KOPIS 에 일정변경으로 다시 등록된 같은 공연을 하나로 합침
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m7s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m52s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m21s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m7s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m52s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m21s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다. 그래서 같은 공연장·같은 날
같은 공연이 두 번 보였다(2026-11-04 회귀 점검: 고령 남진 콘서트).
- 제목 앞의 "[일정변경]" 은 뗀다(화면에는 바뀐 날짜가 이미 보인다)
- 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남기고 나머지는 숨긴다
문화포털 전시에 쓰는 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다. 처음 적용했을 때 음악제 이름을 앞에 붙인
같은 날 다른 공연("포항국제음악제, …" 두 공연)을 합쳐 버려서, 제목이 같을 때만 합치게 했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #6.
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T extends DedupeCandidate>(
|
||||
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<T extends DedupeCandidate>(
|
||||
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 };
|
||||
|
||||
@@ -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('[일정변경]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<FestivalEvent> & {
|
||||
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<string, Partial<FestivalEvent>>();
|
||||
const listed = new Map<string, KopisRow>();
|
||||
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' &&
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user