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
4 changed files with 66 additions and 3 deletions
Showing only changes of commit 4f50df00d8 - Show all commits
@@ -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곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
@@ -89,6 +89,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: '(남산동)' }),
+19 -3
View File
@@ -1,9 +1,9 @@
import { isInKorea } from '@/shared/utils/geo';
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
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 +167,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 +202,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,
+3
View File
@@ -196,6 +196,9 @@ erDiagram
| `other` | VE08 연회장 · VE09 문화원·도서관·학교 | — | (전체에만) | — |
- 저장하지 않는 것: EV 축제·공연·행사(→ `festival_event`), 관광타입 25 여행코스, 좌표가 없거나 한국 밖인 항목.
- 좌표가 주소(없으면 법정동 코드)의 시·도 밖인 항목과, 좌표가 실제 위치가 아닌 것으로 확인한 항목(`place.constants.ts` `TOUR_WRONG_LOCATION_IDS`)도 받지 않는다.
이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. 2026-11-04 회귀 점검: '오천서원'(주소 대구 수성구 · 좌표 포항),
대구올레 팔공산 3·4·5코스(주소·좌표 모두 운영 사무실이라 김광석길에서 '도보 7분'으로 보였다). 실제 좌표를 확인하면 큐레이션으로 되살린다.
- 2026-11-02 대구 도심 쇼핑 135곳 대부분이 `SH040300` **사후면세점**(더현대 입점 브랜드 매장)이었다. 쇼핑은 큐레이션으로만 AI 후보가 된다.
- 분류 코드가 비면 관광타입(12 관광지·14 문화시설·28 레포츠·32 숙박·38 쇼핑·39 음식점)으로 대신한다.