diff --git a/backend/src/modules/place/place.constants.ts b/backend/src/modules/place/place.constants.ts index 4a571f5..920dd74 100644 --- a/backend/src/modules/place/place.constants.ts +++ b/backend/src/modules/place/place.constants.ts @@ -145,6 +145,17 @@ export const MALL_NAME_PREFIXES = [ '동아백화점', ] as const; +/** + * 원본 좌표가 실제 위치가 아닌 관광공사 장소(contentid → 사유) — 원본이 고쳐질 때까지 받지 않는다(목록에서 숨겨진다). + * 주소의 시·도와 맞는지만 보는 검사로는 못 거르는 곳만 둔다. 실제 좌표를 확인하면 큐레이션으로 되살린다. + */ +export const TOUR_WRONG_LOCATION_IDS: Readonly> = { + // 2026-11-04 회귀 점검: 주소·좌표 모두 운영 사무실(수성구 신천동로86안길 14-6)이라 김광석길에서 '도보 7분'으로 보였다. 코스는 팔공산(동구) + '1008532': '[대구올레 팔공산 3코스] 부인사 도보길', + '1008588': '[대구올레 팔공산 4코스] 평광동 왕건길', + '1008614': '[대구올레 팔공산 5코스] 구암마을 가는 길', +}; + /** * 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다. * 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게. 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/festival-mapper.spec.ts b/backend/src/modules/place/sync/festival-mapper.spec.ts index 6dc3ad9..ec7bb64 100644 --- a/backend/src/modules/place/sync/festival-mapper.spec.ts +++ b/backend/src/modules/place/sync/festival-mapper.spec.ts @@ -52,8 +52,9 @@ describe('festivalItemToEvent', () => { ldongRegnCd: '27', ldongSignguCd: '260', phone: '053-666-4842', - imageUrl: 'http://tong.visitkorea.or.kr/f.jpg', - imageThumbUrl: 'http://tong.visitkorea.or.kr/f_s.jpg', + // 관광공사 사진 서버는 https 로 맞춘다 + imageUrl: 'https://tong.visitkorea.or.kr/f.jpg', + imageThumbUrl: 'https://tong.visitkorea.or.kr/f_s.jpg', imageLicense: 'Type3', sourceModifiedAt: new Date('2026-10-20T01:15:00.000Z'), }); diff --git a/backend/src/modules/place/sync/festival-mapper.ts b/backend/src/modules/place/sync/festival-mapper.ts index b4e6a7a..97a8d66 100644 --- a/backend/src/modules/place/sync/festival-mapper.ts +++ b/backend/src/modules/place/sync/festival-mapper.ts @@ -1,5 +1,6 @@ import { isInKorea } from '@/shared/utils/geo'; import { extractUrl, htmlToText } from '@/shared/utils/html-text'; +import { preferHttps } from '@/shared/utils/image-url'; import { parseKstTimestamp } from '@/shared/utils/kst'; import { IMAGE_LICENSES } from '../place.constants'; import type { EventKind, ImageLicense } from '../place.constants'; @@ -88,8 +89,8 @@ export function festivalItemToEvent( ldongRegnCd: str(item, 'lDongRegnCd'), ldongSignguCd: str(item, 'lDongSignguCd'), phone: htmlToText(item.tel), - imageUrl: str(item, 'firstimage'), - imageThumbUrl: str(item, 'firstimage2'), + imageUrl: preferHttps(str(item, 'firstimage')), + imageThumbUrl: preferHttps(str(item, 'firstimage2')), imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '') ? (license as ImageLicense) : null, 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/src/modules/place/sync/tour-mapper.spec.ts b/backend/src/modules/place/sync/tour-mapper.spec.ts index 7f87a20..7a91ee6 100644 --- a/backend/src/modules/place/sync/tour-mapper.spec.ts +++ b/backend/src/modules/place/sync/tour-mapper.spec.ts @@ -70,8 +70,9 @@ describe('listItemToPlace', () => { ldongRegnCd: '27', ldongSignguCd: '110', phone: '053-661-2194', - imageUrl: 'http://tong.visitkorea.or.kr/a.jpg', - imageThumbUrl: 'http://tong.visitkorea.or.kr/a_s.jpg', + // 관광공사 사진 서버는 https 로 맞춘다(웹에서 혼합 콘텐츠로 막히지 않게) + imageUrl: 'https://tong.visitkorea.or.kr/a.jpg', + imageThumbUrl: 'https://tong.visitkorea.or.kr/a_s.jpg', imageLicense: 'Type1', sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'), }); @@ -89,6 +90,39 @@ describe('listItemToPlace', () => { }); }); + it('좌표가 주소의 시·도 밖이면 건너뛴다(오천서원 — 주소 대구 수성구 · 좌표 포항)', () => { + expect( + listItemToPlace( + item({ + contentid: '1955582', + title: '오천서원', + addr1: '대구광역시 수성구 파동로2안길 20', + mapx: '129.3976973279', + mapy: '35.9819418183', + lDongSignguCd: '260', + }), + ), + ).toEqual({ skip: 'coords_mismatch' }); + // 주소가 비면 법정동 시·도 코드로 확인한다 + expect( + listItemToPlace(item({ addr1: '', mapx: '129.39', mapy: '35.98' })), + ).toEqual({ skip: 'coords_mismatch' }); + }); + + it('좌표가 실제 위치가 아닌 것으로 확인한 장소는 건너뛴다(대구올레 팔공산 3코스)', () => { + expect( + listItemToPlace( + item({ + contentid: '1008532', + title: '[대구올레 팔공산 3코스] 부인사 도보길', + addr1: '대구광역시 수성구 신천동로86안길 14-6', + mapx: '128.61234', + mapy: '35.86191', + }), + ), + ).toEqual({ skip: 'wrong_location' }); + }); + it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => { const p = listItemToPlace( item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }), diff --git a/backend/src/modules/place/sync/tour-mapper.ts b/backend/src/modules/place/sync/tour-mapper.ts index 2d1b696..2dfd638 100644 --- a/backend/src/modules/place/sync/tour-mapper.ts +++ b/backend/src/modules/place/sync/tour-mapper.ts @@ -1,9 +1,10 @@ -import { isInKorea } from '@/shared/utils/geo'; +import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo'; import { extractUrl, htmlToText } from '@/shared/utils/html-text'; +import { preferHttps } from '@/shared/utils/image-url'; import { parseKstTimestamp } from '@/shared/utils/kst'; import { parseOpeningHours } from '@/shared/utils/opening-hours'; import type { ParsedOpeningHours } from '@/shared/utils/opening-hours'; -import { IMAGE_LICENSES } from '../place.constants'; +import { IMAGE_LICENSES, TOUR_WRONG_LOCATION_IDS } from '../place.constants'; import type { ImageLicense, PlaceCategory, @@ -167,7 +168,19 @@ export interface TourListPlace { sourceModifiedAt: Date | null; } -export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id'; +/** + * - bad_coords: 좌표가 없거나 한국 밖 + * - coords_mismatch: 좌표가 주소의 시·도와 다른 곳(2026-11-04 '오천서원' — 주소 대구 수성구 · 좌표 포항) + * - wrong_location: 좌표가 실제 위치가 아닌 것으로 확인한 장소(TOUR_WRONG_LOCATION_IDS) + * 건너뛴 장소는 저장하지 않으므로, 이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. + */ +export type SkipReason = + | 'not_place' + | 'no_category' + | 'bad_coords' + | 'coords_mismatch' + | 'wrong_location' + | 'no_id'; /** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */ export function listItemToPlace( @@ -190,11 +203,15 @@ export function listItemToPlace( if (!isInKorea(lat, lng)) { return { skip: 'bad_coords' }; } + if (TOUR_WRONG_LOCATION_IDS[sourceId]) return { skip: 'wrong_location' }; const license = str(item, 'cpyrhtDivCd'); const address = [str(item, 'addr1'), str(item, 'addr2')] .filter(Boolean) .join(' '); + // 거리·지도가 엉뚱한 곳을 가리키지 않게, 주소(없으면 법정동 코드)의 시·도 밖 좌표는 받지 않는다 + const sido = sidoCodeFromAddress(address) ?? str(item, 'lDongRegnCd'); + if (sido && !isInSido(sido, lat, lng)) return { skip: 'coords_mismatch' }; return { source: 'tour', sourceId, @@ -208,8 +225,8 @@ export function listItemToPlace( ldongRegnCd: str(item, 'lDongRegnCd'), ldongSignguCd: str(item, 'lDongSignguCd'), phone: htmlToText(item.tel), - imageUrl: str(item, 'firstimage'), - imageThumbUrl: str(item, 'firstimage2'), + imageUrl: preferHttps(str(item, 'firstimage')), + imageThumbUrl: preferHttps(str(item, 'firstimage2')), imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '') ? (license as ImageLicense) : null, diff --git a/backend/src/shared/utils/image-url.spec.ts b/backend/src/shared/utils/image-url.spec.ts new file mode 100644 index 0000000..c95b314 --- /dev/null +++ b/backend/src/shared/utils/image-url.spec.ts @@ -0,0 +1,26 @@ +import { preferHttps } from './image-url'; + +describe('preferHttps', () => { + it('관광공사 사진 서버의 http 주소를 https 로 바꾼다', () => { + expect( + preferHttps('http://tong.visitkorea.or.kr/cms/resource/a_image2_1.jpg'), + ).toBe('https://tong.visitkorea.or.kr/cms/resource/a_image2_1.jpg'); + expect(preferHttps('HTTP://Tong.VisitKorea.or.kr/a.jpg')).toBe( + 'https://Tong.VisitKorea.or.kr/a.jpg', + ); + }); + + it('이미 https 이거나 확인하지 않은 서버·빈 값은 그대로 둔다', () => { + expect(preferHttps('https://tong.visitkorea.or.kr/a.jpg')).toBe( + 'https://tong.visitkorea.or.kr/a.jpg', + ); + expect(preferHttps('http://www.kopis.or.kr/a.jpg')).toBe( + 'http://www.kopis.or.kr/a.jpg', + ); + expect(preferHttps('http://tong.visitkorea.or.kr.evil.example/a.jpg')).toBe( + 'http://tong.visitkorea.or.kr.evil.example/a.jpg', + ); + expect(preferHttps(null)).toBeNull(); + expect(preferHttps('')).toBe(''); + }); +}); diff --git a/backend/src/shared/utils/image-url.ts b/backend/src/shared/utils/image-url.ts new file mode 100644 index 0000000..34a7df1 --- /dev/null +++ b/backend/src/shared/utils/image-url.ts @@ -0,0 +1,18 @@ +/** + * 사진 주소 정리 (순수 함수). + */ + +/** + * 같은 파일을 https 로도 주는 것을 확인한 사진 서버. + * 2026-11-04 대구 관광공사 장소 사진 1,059건 중 780건은 https, 279건은 http 였다(같은 서버). + * https 페이지(웹)에서는 http 사진이 혼합 콘텐츠로 막히므로 저장할 때 https 로 맞춘다. + */ +const HTTPS_IMAGE_HOSTS = new Set(['tong.visitkorea.or.kr']); + +/** 확인한 서버의 http 사진 주소를 https 로 바꾼다. 다른 서버·형식은 그대로 둔다 */ +export function preferHttps(url: string | null): string | null { + if (!url) return url; + const m = /^http:\/\/([^/?#:]+)/i.exec(url); + if (!m || !HTTPS_IMAGE_HOSTS.has(m[1].toLowerCase())) return url; + return `https://${url.slice('http://'.length)}`; +} 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 312e330..c12d10a 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -196,6 +196,11 @@ erDiagram | `other` | VE08 연회장 · VE09 문화원·도서관·학교 | — | (전체에만) | — | - 저장하지 않는 것: EV 축제·공연·행사(→ `festival_event`), 관광타입 25 여행코스, 좌표가 없거나 한국 밖인 항목. +- 좌표가 주소(없으면 법정동 코드)의 시·도 밖인 항목과, 좌표가 실제 위치가 아닌 것으로 확인한 항목(`place.constants.ts` `TOUR_WRONG_LOCATION_IDS`)도 받지 않는다. + 이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. 2026-11-04 회귀 점검: '오천서원'(주소 대구 수성구 · 좌표 포항), + 대구올레 팔공산 3·4·5코스(주소·좌표 모두 운영 사무실이라 김광석길에서 '도보 7분'으로 보였다). 실제 좌표를 확인하면 큐레이션으로 되살린다. +- 관광공사 사진 서버(`tong.visitkorea.or.kr`)의 `http://` 주소는 `https://` 로 바꿔 저장한다(`image-url.ts` `preferHttps`). + 2026-11-04 대구 장소 사진 1,059건 중 279건이 http 였다(같은 서버가 https 로도 준다). 웹(https 페이지)에서는 http 사진이 막힌다. - 2026-11-02 대구 도심 쇼핑 135곳 대부분이 `SH040300` **사후면세점**(더현대 입점 브랜드 매장)이었다. 쇼핑은 큐레이션으로만 AI 후보가 된다. - 분류 코드가 비면 관광타입(12 관광지·14 문화시설·28 레포츠·32 숙박·38 쇼핑·39 음식점)으로 대신한다. @@ -422,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` 요일 해석은 나중에).