fix(backend): 좌표가 주소와 맞지 않거나 실제 위치가 아닌 관광공사 장소를 받지 않음

관광공사 원본 좌표가 실제 위치가 아닌 장소가 거리·지도에 엉뚱하게 나왔다(2026-11-04 회귀 점검).
- 오천서원: 주소는 대구 수성구인데 좌표는 포항이라 대구 목록에 70km 떨어진 곳으로 나왔다
- 대구올레 팔공산 3·4·5코스: 주소·좌표 모두 운영 사무실(수성구)이라 김광석길에서 '도보 7분'으로 보였다

좌표가 주소(없으면 법정동 코드)의 시·도 밖이면 받지 않는다(geo.ts isInSido 재사용).
시·도 검사로 못 거르는 곳은 확인한 contentid 목록(TOUR_WRONG_LOCATION_IDS)으로 받지 않는다.
이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. 관광공사 장소 1,174곳에 적용해 이 4곳만 걸리는 것을 확인했고,
개발 DB 는 tour-kor --skip-details 로 반영했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 13:37:15 +09:00
parent d4d99c9127
commit 4f50df00d8
4 changed files with 66 additions and 3 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곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
@@ -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,