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/tour-mapper.spec.ts b/backend/src/modules/place/sync/tour-mapper.spec.ts index 7f87a20..fce29fd 100644 --- a/backend/src/modules/place/sync/tour-mapper.spec.ts +++ b/backend/src/modules/place/sync/tour-mapper.spec.ts @@ -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: '(남산동)' }), diff --git a/backend/src/modules/place/sync/tour-mapper.ts b/backend/src/modules/place/sync/tour-mapper.ts index 2d1b696..2e4995c 100644 --- a/backend/src/modules/place/sync/tour-mapper.ts +++ b/backend/src/modules/place/sync/tour-mapper.ts @@ -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, diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 312e330..bc8aa50 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -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 음식점)으로 대신한다.