generated from ttipo/project-base
feat(backend): 같은 출처에 두 번 등록된 전시를 하나로 합침
한국문화정보원에 같은 전시가 제목만 다르게 두 번 등록되어 목록에 2건으로 나왔다
("문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》", 같은 갤러리·같은 기간).
같은 장소 · 같은 시작·종료일 · 제목 비교 키가 연속 6글자 이상 겹치면 같은 전시로 보고
먼저 등록된 것만 남긴다. 장소·기간만으로 합치지 않는 이유는 여러 전시실에서 같은 날
여러 전시를 여는 곳이 있어서다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜)
|
||||
|
||||
Reference in New Issue
Block a user