From 307204ef096013b602a767446ab7d693b3076ea1 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 22:13:03 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20KOPIS=20=EA=B3=B5=EC=97=B0=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=EC=9D=98=20=EC=A7=80=EC=97=AD=20=EA=BC=AC?= =?UTF-8?q?=EB=A6=AC=ED=91=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 순회 공연은 제목 끝에 "[대구]"·"[대구 북구]"·"[안동] (11월)" 같은 지역 표시가 붙어 온다 (대구·경북 244건 중 203건). 앱 목록에서는 군더더기라 저장할 때 뗀다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)" 같은 메모는 남긴다. Co-Authored-By: Claude Opus 5.5 --- .../modules/place/sync/kopis-mapper.spec.ts | 33 +++++++++++ .../src/modules/place/sync/kopis-mapper.ts | 58 ++++++++++++++++++- docs/design/place-db.md | 2 + 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/place/sync/kopis-mapper.spec.ts b/backend/src/modules/place/sync/kopis-mapper.spec.ts index 3b5b6e7..4c5f8e8 100644 --- a/backend/src/modules/place/sync/kopis-mapper.spec.ts +++ b/backend/src/modules/place/sync/kopis-mapper.spec.ts @@ -5,6 +5,7 @@ import { kopisListItemToEvent, kopisTimestamp, kopisVenue, + stripRegionTag, } from './kopis-mapper'; describe('kopisDate', () => { @@ -205,3 +206,35 @@ describe('kopisVenue', () => { }); }); }); + +describe('stripRegionTag', () => { + it('제목 끝의 지역 꼬리표를 뗀다(2026-11-03 실제 제목)', () => { + expect(stripRegionTag('굿프렌즈 [대구]')).toBe('굿프렌즈'); + expect( + stripRegionTag('데뷔 60주년 기념공연, 남진 전국투어 콘서트 [대구 북구]'), + ).toBe('데뷔 60주년 기념공연, 남진 전국투어 콘서트'); + expect( + stripRegionTag('V.O.S 전국투어 콘서트: 제2회 동창회 [대구 경산]'), + ).toBe('V.O.S 전국투어 콘서트: 제2회 동창회'); + }); + + it('꼬리표 안팎의 괄호 메모(앵콜·회차)는 남긴다', () => { + expect(stripRegionTag('무명전설 전국투어 콘서트 [대구 (앵콜) ]')).toBe( + '무명전설 전국투어 콘서트 (앵콜)', + ); + expect(stripRegionTag('Jazz in Blue 민경인 트리오 [안동] (11월)')).toBe( + 'Jazz in Blue 민경인 트리오 (11월)', + ); + expect(stripRegionTag('온 더 레드카펫 [대구] (11.10)')).toBe( + '온 더 레드카펫 (11.10)', + ); + }); + + it('지역이 아닌 꼬리표·가운데 꼬리표·꼬리표뿐인 제목은 그대로 둔다', () => { + expect(stripRegionTag('라라라온 [롴돰 로컬다움]')).toBe( + '라라라온 [롴돰 로컬다움]', + ); + expect(stripRegionTag('[대구] 송년 음악회')).toBe('[대구] 송년 음악회'); + expect(stripRegionTag('[대구]')).toBe('[대구]'); + }); +}); diff --git a/backend/src/modules/place/sync/kopis-mapper.ts b/backend/src/modules/place/sync/kopis-mapper.ts index 862c43f..b369e31 100644 --- a/backend/src/modules/place/sync/kopis-mapper.ts +++ b/backend/src/modules/place/sync/kopis-mapper.ts @@ -17,6 +17,35 @@ const KOPIS_ID_RE = /^[A-Za-z0-9]+$/; /** 끝난 공연 — 기간이 겹쳐 목록에 와도 넣지 않는다 */ const ENDED_STATE = '공연완료'; +/** + * 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]")에 쓰이는 이름 — 시·도와 대구·경북 시군구. + * 2026-11-03 대구·경북 공연 244건 중 203건에 붙어 있었다("[대구]" 141 · "[안동]" 15 · "[대구 북구]" …). + */ +const REGION_TAG_WORDS = new Set([ + // 시·도 + ...['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종'], + ...['경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'], + // 대구 구·군 + ...[ + '중구', + '동구', + '서구', + '남구', + '북구', + '수성', + '수성구', + '달서', + '달서구', + ], + ...['달성', '달성군', '군위', '군위군'], + // 경북 시·군 + ...['포항', '경주', '김천', '안동', '구미', '영주', '영천', '상주', '문경'], + ...['경산', '의성', '청송', '영양', '영덕', '청도', '고령', '성주', '칠곡'], + ...['예천', '봉화', '울진', '울릉'], +]); +/** 제목 끝의 [꼬리표] 와 그 뒤에 붙는 회차 표시("(11월)", "(11.10)") */ +const TRAILING_TAG_RE = /\s*\[([^[\]]*)\](\s*\([^()]*\))?\s*$/; + export interface KopisListEvent { sourceId: string; name: string; @@ -67,12 +96,36 @@ export function kopisImageUrl(value: string | null): string | null { return /^https:\/\//i.test(value) ? value : null; } +/** + * 제목 끝의 지역 꼬리표를 뗀다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, 메모는 남긴다. + * "굿프렌즈 [대구]" → "굿프렌즈" · "무명전설 콘서트 [대구 (앵콜) ]" → "무명전설 콘서트 (앵콜)" + * "… 트리오 [안동] (11월)" → "… 트리오 (11월)" · "라라라온 [롴돰 로컬다움]" → 그대로 + */ +export function stripRegionTag(name: string): string { + const m = TRAILING_TAG_RE.exec(name); + if (!m || m.index === 0) return name; + const notes = m[1].match(/\([^()]*\)/g) ?? []; + const words = m[1] + .replace(/\([^()]*\)/g, ' ') + .split(/\s+/) + .filter(Boolean); + if (words.length === 0 || !words.every((w) => REGION_TAG_WORDS.has(w))) { + return name; + } + const suffix = [...notes, m[2]?.trim()] + .filter((v): v is string => !!v) + .map((v) => v.replace(/\(\s*/, '(').replace(/\s*\)/, ')')) + .join(' '); + return [name.slice(0, m.index), suffix].filter(Boolean).join(' ').trim(); +} + /** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */ export function kopisListItemToEvent( item: KopisItem, ): KopisListEvent | { skip: KopisSkipReason } { const sourceId = str(item, 'mt20id'); - const name = htmlToText(item.prfnm); + const title = htmlToText(item.prfnm); + const name = title ? stripRegionTag(title) : null; if (!sourceId || !KOPIS_ID_RE.test(sourceId) || !name) { return { skip: 'no_id' }; } @@ -140,8 +193,9 @@ export function kopisDetail(db: KopisItem): KopisDetail { const endDate = kopisDate(str(db, 'prfpdto')); const validPeriod = startDate !== null && endDate !== null && endDate >= startDate; + const title = htmlToText(db.prfnm); return { - name: htmlToText(db.prfnm), + name: title ? stripRegionTag(title) : null, startDate: validPeriod ? startDate : null, endDate: validPeriod ? endDate : null, genre: str(db, 'genrenm'), diff --git a/docs/design/place-db.md b/docs/design/place-db.md index d08a66f..1c71719 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -406,6 +406,8 @@ node dist/commands/place-sync.js tour-kor 3. **공연시설** `prfplc/{mt10id}` — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 30일 지난 시설만 → `event_venue`. **좌표는 주소의 시·도 영역 안일 때만 쓴다**(`geo.ts` `isInSido`). 아니면 비우고 경고를 남긴다(그 공연은 근처 조회에서만 빠진다). 4. 시설의 좌표·주소·전화를 공연에 복사한다. 조회 API 가 공연 좌표로 거리를 계산하기 때문이다. 시설 좌표를 고치면 다음 실행에 공연도 바뀐다. +- 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]", "[대구 북구]", "[안동] (11월)")를 떼어 저장한다. 2026-11-03 244건 중 203건에 붙어 있었다. + 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)"·"(11.10)" 같은 메모는 남긴다(`kopis-mapper.ts` `stripRegionTag`). - 포스터는 `http://www.kopis.or.kr/…` 로 온다. 앱이 http 이미지를 막으므로 `https://kopis.or.kr/…` 로 바꿔 저장한다(목록·상세 사진 모두 포스터). - 호출: 0.6초 간격(KOPIS 는 초당 10회에서 차단). 오퍼레이션별(`pblprfr`·`pblprfr-detail`·`prfplc-detail`) 하루 상한 980. 공식 한도는 밝혀져 있지 않다. 이후 하루 호출은 목록 4회 + 상세 수십 회로 예상한다.