generated from ttipo/project-base
Compare commits
2 Commits
7e59ac0205
...
170be20b6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 170be20b6d | |||
| e383c2e242 |
@@ -59,6 +59,10 @@ describe('formatSomething', () => {
|
||||
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
|
||||
> ```
|
||||
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
|
||||
>
|
||||
> e2e 스위트는 **한 DB 를 함께 쓰므로 순서대로 돈다**(`jest-e2e.json` 의 `maxWorkers: 1`).
|
||||
> 빈 DB 에서 여러 스위트가 동시에 마이그레이션하면 같은 테이블을 만들다 부딪혀 무작위로 실패한다(2026-11-03, 스위트 5개에서 재현).
|
||||
> 새 e2e 스위트는 실제 데이터와 겹치지 않는 값(없는 지역 코드 `'99'`, `e2e-` 접두어 id, 2030년 날짜 등)만 쓰고 끝나면 지운다.
|
||||
|
||||
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
|
||||
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜)
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"maxWorkers": 1
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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` 경도). 지역의 시·도 영역 밖이면 버린다.
|
||||
|
||||
Reference in New Issue
Block a user