fix(backend): 원본 좌표 오류 장소 제외 · 관광공사 사진 https · KOPIS 일정변경 중복 정리 #6

Merged
ttipo merged 3 commits from fix/place-data-cleanup into main 2026-11-05 15:34:16 +09:00
14 changed files with 297 additions and 24 deletions
@@ -145,6 +145,17 @@ export const MALL_NAME_PREFIXES = [
'동아백화점',
] as const;
/**
* 원본 좌표가 실제 위치가 아닌 관광공사 장소(contentid → 사유) — 원본이 고쳐질 때까지 받지 않는다(목록에서 숨겨진다).
* 주소의 시·도와 맞는지만 보는 검사로는 못 거르는 곳만 둔다. 실제 좌표를 확인하면 큐레이션으로 되살린다.
*/
export const TOUR_WRONG_LOCATION_IDS: Readonly<Record<string, string>> = {
// 2026-11-04 회귀 점검: 주소·좌표 모두 운영 사무실(수성구 신천동로86안길 14-6)이라 김광석길에서 '도보 7분'으로 보였다. 코스는 팔공산(동구)
'1008532': '[대구올레 팔공산 3코스] 부인사 도보길',
'1008588': '[대구올레 팔공산 4코스] 평광동 왕건길',
'1008614': '[대구올레 팔공산 5코스] 구암마을 가는 길',
};
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
@@ -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([]);
});
});
+24 -2
View File
@@ -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 };
@@ -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'),
});
@@ -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,
@@ -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('[일정변경]');
});
});
+15 -2
View File
@@ -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'),
+36 -8
View File
@@ -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' &&
@@ -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: '(남산동)' }),
+22 -5
View File
@@ -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,
@@ -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('');
});
});
+18
View File
@@ -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)}`;
}
+36
View File
@@ -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');
});
});
+11 -1
View File
@@ -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` 요일 해석은 나중에).