feat(app): add manual entry and sharing flows
This commit is contained in:
@@ -5,37 +5,234 @@ import 'package:flutter/foundation.dart';
|
||||
class NaverHtmlExtractor {
|
||||
// 제외할 UI 텍스트 패턴 (확장)
|
||||
static const List<String> _excludePatterns = [
|
||||
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
|
||||
'전화번호', '주소', '찾아오시는길', '예약', '홈', '이용약관', '개인정보',
|
||||
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
|
||||
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
|
||||
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
|
||||
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
|
||||
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
|
||||
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
|
||||
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
|
||||
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
|
||||
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
|
||||
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
|
||||
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
|
||||
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
|
||||
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
|
||||
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
|
||||
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
|
||||
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
|
||||
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
|
||||
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
|
||||
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
|
||||
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
|
||||
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
|
||||
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
|
||||
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
|
||||
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
|
||||
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
|
||||
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
|
||||
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
|
||||
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
|
||||
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
|
||||
'로그인',
|
||||
'메뉴',
|
||||
'검색',
|
||||
'지도',
|
||||
'리뷰',
|
||||
'사진',
|
||||
'네이버',
|
||||
'영업시간',
|
||||
'전화번호',
|
||||
'주소',
|
||||
'찾아오시는길',
|
||||
'예약',
|
||||
'홈',
|
||||
'이용약관',
|
||||
'개인정보',
|
||||
'고객센터',
|
||||
'신고',
|
||||
'공유',
|
||||
'즐겨찾기',
|
||||
'길찾기',
|
||||
'거리뷰',
|
||||
'저장',
|
||||
'더보기',
|
||||
'접기',
|
||||
'펼치기',
|
||||
'닫기',
|
||||
'취소',
|
||||
'확인',
|
||||
'선택',
|
||||
'전체',
|
||||
'삭제',
|
||||
'플레이스',
|
||||
'지도보기',
|
||||
'상세보기',
|
||||
'평점',
|
||||
'별점',
|
||||
'추천',
|
||||
'인기',
|
||||
'최신',
|
||||
'오늘',
|
||||
'내일',
|
||||
'영업중',
|
||||
'영업종료',
|
||||
'휴무',
|
||||
'정기휴무',
|
||||
'임시휴무',
|
||||
'배달',
|
||||
'포장',
|
||||
'매장',
|
||||
'주차',
|
||||
'단체석',
|
||||
'예약가능',
|
||||
'대기',
|
||||
'웨이팅',
|
||||
'영수증',
|
||||
'현금',
|
||||
'카드',
|
||||
'계산서',
|
||||
'할인',
|
||||
'쿠폰',
|
||||
'적립',
|
||||
'포인트',
|
||||
'회원',
|
||||
'비회원',
|
||||
'로그아웃',
|
||||
'마이페이지',
|
||||
'알림',
|
||||
'설정',
|
||||
'도움말',
|
||||
'문의',
|
||||
'제보',
|
||||
'수정',
|
||||
'삭제',
|
||||
'등록',
|
||||
'작성',
|
||||
'댓글',
|
||||
'답글',
|
||||
'좋아요',
|
||||
'싫어요',
|
||||
'스크랩',
|
||||
'북마크',
|
||||
'태그',
|
||||
'해시태그',
|
||||
'팔로우',
|
||||
'팔로잉',
|
||||
'팔로워',
|
||||
'차단',
|
||||
'신고하기',
|
||||
'게시물',
|
||||
'프로필',
|
||||
'활동',
|
||||
'통계',
|
||||
'분석',
|
||||
'다운로드',
|
||||
'업로드',
|
||||
'첨부',
|
||||
'파일',
|
||||
'이미지',
|
||||
'동영상',
|
||||
'음성',
|
||||
'링크',
|
||||
'복사',
|
||||
'붙여넣기',
|
||||
'되돌리기',
|
||||
'다시실행',
|
||||
'새로고침',
|
||||
'뒤로',
|
||||
'앞으로',
|
||||
'시작',
|
||||
'종료',
|
||||
'일시정지',
|
||||
'재생',
|
||||
'정지',
|
||||
'음량',
|
||||
'화면',
|
||||
'전체화면',
|
||||
'최소화',
|
||||
'최대화',
|
||||
'창닫기',
|
||||
'새창',
|
||||
'새탭',
|
||||
'인쇄',
|
||||
'저장하기',
|
||||
'열기',
|
||||
'가져오기',
|
||||
'내보내기',
|
||||
'동기화',
|
||||
'백업',
|
||||
'복원',
|
||||
'초기화',
|
||||
'재설정',
|
||||
'업데이트',
|
||||
'버전',
|
||||
'정보',
|
||||
'소개',
|
||||
'안내',
|
||||
'공지',
|
||||
'이벤트',
|
||||
'혜택',
|
||||
'쿠키',
|
||||
'개인정보처리방침',
|
||||
'서비스이용약관',
|
||||
'위치정보이용약관',
|
||||
'청소년보호정책',
|
||||
'저작권',
|
||||
'라이선스',
|
||||
'제휴',
|
||||
'광고',
|
||||
'비즈니스',
|
||||
'개발자',
|
||||
'API',
|
||||
'오픈소스',
|
||||
'기여',
|
||||
'후원',
|
||||
'기부',
|
||||
'결제',
|
||||
'환불',
|
||||
'교환',
|
||||
'반품',
|
||||
'배송',
|
||||
'택배',
|
||||
'운송장',
|
||||
'추적',
|
||||
'도착',
|
||||
'출발',
|
||||
'네이버 지도',
|
||||
'카카오맵',
|
||||
'구글맵',
|
||||
'T맵',
|
||||
'지도 앱',
|
||||
'내비게이션',
|
||||
'경로',
|
||||
'소요시간',
|
||||
'거리',
|
||||
'도보',
|
||||
'자전거',
|
||||
'대중교통',
|
||||
'자동차',
|
||||
'지하철',
|
||||
'버스',
|
||||
'택시',
|
||||
'기차',
|
||||
'비행기',
|
||||
'선박',
|
||||
'도보',
|
||||
'환승',
|
||||
'출구',
|
||||
'입구',
|
||||
'승강장',
|
||||
'매표소',
|
||||
'화장실',
|
||||
'편의시설',
|
||||
'주차장',
|
||||
'엘리베이터',
|
||||
'에스컬레이터',
|
||||
'계단',
|
||||
'경사로',
|
||||
'점자블록',
|
||||
'휠체어',
|
||||
'유모차',
|
||||
'애완동물',
|
||||
'흡연',
|
||||
'금연',
|
||||
'와이파이',
|
||||
'콘센트',
|
||||
'충전',
|
||||
'PC',
|
||||
'프린터',
|
||||
'팩스',
|
||||
'복사기',
|
||||
'회의실',
|
||||
'세미나실',
|
||||
'강당',
|
||||
'공연장',
|
||||
'전시장',
|
||||
'박물관',
|
||||
'미술관',
|
||||
'도서관',
|
||||
'체육관',
|
||||
'수영장',
|
||||
'운동장',
|
||||
'놀이터',
|
||||
'공원',
|
||||
'산책로',
|
||||
'자전거도로',
|
||||
'등산로',
|
||||
'캠핑장',
|
||||
'낚시터',
|
||||
];
|
||||
|
||||
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
|
||||
@@ -52,21 +249,35 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
|
||||
final contentTags = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'span', 'div', 'li', 'td', 'th',
|
||||
'strong', 'em', 'b', 'i', 'a'
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'p',
|
||||
'span',
|
||||
'div',
|
||||
'li',
|
||||
'td',
|
||||
'th',
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'a',
|
||||
];
|
||||
|
||||
final tagPattern = contentTags.map((tag) =>
|
||||
'<$tag[^>]*>([^<]+)</$tag>'
|
||||
).join('|');
|
||||
final tagPattern = contentTags
|
||||
.map((tag) => '<$tag[^>]*>([^<]+)</$tag>')
|
||||
.join('|');
|
||||
|
||||
final tagRegex = RegExp(tagPattern, multiLine: true, caseSensitive: false);
|
||||
final tagMatches = tagRegex.allMatches(cleanHtml);
|
||||
|
||||
// 추출된 텍스트 수집
|
||||
final extractedTexts = <String>[];
|
||||
|
||||
|
||||
for (final match in tagMatches) {
|
||||
final text = match.group(1)?.trim() ?? '';
|
||||
if (text.isNotEmpty && text.contains(RegExp(r'[가-힣]'))) {
|
||||
@@ -77,11 +288,11 @@ class NaverHtmlExtractor {
|
||||
// 모든 태그 제거 후 남은 텍스트도 추가
|
||||
final textOnly = cleanHtml.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
final cleanedText = textOnly.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
|
||||
// 한글 텍스트 추출
|
||||
final koreanPattern = RegExp(r'[가-힣]+(?:\s[가-힣]+)*');
|
||||
final koreanMatches = koreanPattern.allMatches(cleanedText);
|
||||
|
||||
|
||||
for (final match in koreanMatches) {
|
||||
final text = match.group(0)?.trim() ?? '';
|
||||
if (text.length >= 2) {
|
||||
@@ -91,17 +302,19 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 중복 제거 및 필터링
|
||||
final uniqueTexts = <String>{};
|
||||
|
||||
|
||||
for (final text in extractedTexts) {
|
||||
// UI 패턴 제외
|
||||
bool isExcluded = false;
|
||||
for (final pattern in _excludePatterns) {
|
||||
if (text == pattern || text.startsWith(pattern) || text.endsWith(pattern)) {
|
||||
if (text == pattern ||
|
||||
text.startsWith(pattern) ||
|
||||
text.endsWith(pattern)) {
|
||||
isExcluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!isExcluded && text.length >= 2 && text.length <= 50) {
|
||||
uniqueTexts.add(text);
|
||||
}
|
||||
@@ -109,13 +322,13 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@@ -217,7 +430,7 @@ class NaverHtmlExtractor {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// HTML에서 Place URL 추출 (og:url 메타 태그)
|
||||
static String? extractPlaceLink(String html) {
|
||||
try {
|
||||
@@ -232,7 +445,7 @@ class NaverHtmlExtractor {
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
// canonical 링크 태그에서 추출
|
||||
final canonicalRegex = RegExp(
|
||||
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
|
||||
@@ -247,7 +460,7 @@ class NaverHtmlExtractor {
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
|
||||
class NaverHtmlParser {
|
||||
// CSS 셀렉터 상수
|
||||
@@ -13,38 +13,38 @@ class NaverHtmlParser {
|
||||
'[class*="place_name"]',
|
||||
'meta[property="og:title"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _categorySelectors = [
|
||||
'span.DJJvD',
|
||||
'span.lnJFt',
|
||||
'[class*="category"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _descriptionSelectors = [
|
||||
'span.IH7VW',
|
||||
'div.vV_z_',
|
||||
'meta[name="description"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _phoneSelectors = [
|
||||
'span.xlx7Q',
|
||||
'a[href^="tel:"]',
|
||||
'[class*="phone"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _addressSelectors = [
|
||||
'span.IH7VW',
|
||||
'span.jWDO_',
|
||||
'[class*="address"]',
|
||||
];
|
||||
|
||||
|
||||
static const List<String> _businessHoursSelectors = [
|
||||
'time.aT6WB',
|
||||
'div.O8qbU',
|
||||
'[class*="business"]',
|
||||
'[class*="hours"]',
|
||||
];
|
||||
|
||||
|
||||
/// HTML 문서에서 식당 정보 추출
|
||||
Map<String, dynamic> parseRestaurantInfo(Document document) {
|
||||
return {
|
||||
@@ -60,7 +60,7 @@ class NaverHtmlParser {
|
||||
'businessHours': extractBusinessHours(document),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름 추출
|
||||
String? extractName(Document document) {
|
||||
try {
|
||||
@@ -82,7 +82,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 카테고리 추출
|
||||
String? extractCategory(Document document) {
|
||||
try {
|
||||
@@ -102,7 +102,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 서브 카테고리 추출
|
||||
String? extractSubCategory(Document document) {
|
||||
try {
|
||||
@@ -120,7 +120,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 설명 추출
|
||||
String? extractDescription(Document document) {
|
||||
try {
|
||||
@@ -142,7 +142,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 전화번호 추출
|
||||
String? extractPhoneNumber(Document document) {
|
||||
try {
|
||||
@@ -164,7 +164,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 도로명 주소 추출
|
||||
String? extractRoadAddress(Document document) {
|
||||
try {
|
||||
@@ -184,7 +184,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 지번 주소 추출
|
||||
String? extractJibunAddress(Document document) {
|
||||
try {
|
||||
@@ -193,8 +193,8 @@ class NaverHtmlParser {
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
!text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 위도 추출
|
||||
double? extractLatitude(Document document) {
|
||||
try {
|
||||
@@ -223,7 +223,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -236,14 +236,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 경도 추출
|
||||
double? extractLongitude(Document document) {
|
||||
try {
|
||||
@@ -260,7 +260,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -273,14 +273,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 영업시간 추출
|
||||
String? extractBusinessHours(Document document) {
|
||||
try {
|
||||
@@ -288,10 +288,10 @@ class NaverHtmlParser {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -302,4 +302,4 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,34 +18,37 @@ import '../../../core/utils/category_mapper.dart';
|
||||
class NaverMapParser {
|
||||
// URL 관련 상수
|
||||
static const String _naverMapBaseUrl = 'https://map.naver.com';
|
||||
|
||||
|
||||
// 정규식 패턴
|
||||
static final RegExp _placeIdRegex = RegExp(r'/p/(?:restaurant|entry/place)/(\d+)');
|
||||
static final RegExp _placeIdRegex = RegExp(
|
||||
r'/p/(?:restaurant|entry/place)/(\d+)',
|
||||
);
|
||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||
|
||||
|
||||
// 기본 좌표 (서울 시청)
|
||||
static const double _defaultLatitude = 37.5666805;
|
||||
static const double _defaultLongitude = 126.9784147;
|
||||
|
||||
|
||||
// API 요청 관련 상수
|
||||
static const int _shortDelayMillis = 500;
|
||||
static const int _longDelayMillis = 1000;
|
||||
static const int _searchDisplayCount = 10;
|
||||
static const double _coordinateConversionFactor = 10000000.0;
|
||||
|
||||
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverHtmlParser _htmlParser = NaverHtmlParser();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
bool _isDisposed = false;
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
: _apiClient = apiClient ?? NaverApiClient();
|
||||
|
||||
|
||||
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
|
||||
///
|
||||
///
|
||||
/// 지원하는 URL 형식:
|
||||
/// - https://map.naver.com/p/restaurant/1234567890
|
||||
/// - https://naver.me/abcdefgh
|
||||
///
|
||||
///
|
||||
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
|
||||
/// 가장 가까운 위치의 식당을 선택합니다.
|
||||
Future<Restaurant> parseRestaurantFromUrl(
|
||||
@@ -53,23 +56,26 @@ class NaverMapParser {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
if (_isDisposed) {
|
||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||
}
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
||||
}
|
||||
|
||||
|
||||
// URL 유효성 검증
|
||||
if (!_isValidNaverUrl(url)) {
|
||||
throw NaverMapParseException('유효하지 않은 네이버 지도 URL입니다: $url');
|
||||
}
|
||||
|
||||
|
||||
// 짧은 URL인 경우 리다이렉트 처리
|
||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
}
|
||||
|
||||
|
||||
// Place ID 추출 (10자리 숫자)
|
||||
final String? placeId = _extractPlaceId(finalUrl);
|
||||
if (placeId == null) {
|
||||
@@ -77,24 +83,31 @@ class NaverMapParser {
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Using short URL ID as place ID: $shortUrlId');
|
||||
debugPrint(
|
||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
||||
);
|
||||
}
|
||||
return _createFallbackRestaurant(shortUrlId, url);
|
||||
}
|
||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||
}
|
||||
|
||||
|
||||
// 단축 URL인 경우 특별 처리
|
||||
final isShortUrl = url.contains('naver.me');
|
||||
|
||||
|
||||
if (isShortUrl) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||
final restaurant = await _parseWithLocalSearch(placeId, finalUrl, userLatitude, userLongitude);
|
||||
final restaurant = await _parseWithLocalSearch(
|
||||
placeId,
|
||||
finalUrl,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
@@ -106,7 +119,7 @@ class NaverMapParser {
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
|
||||
final restaurantData = await _fetchRestaurantFromGraphQL(
|
||||
placeId,
|
||||
@@ -114,7 +127,6 @@ class NaverMapParser {
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
rethrow;
|
||||
@@ -128,7 +140,7 @@ class NaverMapParser {
|
||||
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// URL이 유효한 네이버 지도 URL인지 확인
|
||||
bool _isValidNaverUrl(String url) {
|
||||
try {
|
||||
@@ -138,15 +150,15 @@ class NaverMapParser {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// _resolveFinalUrl 메서드는 이제 NaverApiClient.resolveShortUrl로 대체됨
|
||||
|
||||
|
||||
/// URL에서 Place ID 추출
|
||||
String? _extractPlaceId(String url) {
|
||||
final match = _placeIdRegex.firstMatch(url);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
|
||||
/// 짧은 URL에서 ID 추출
|
||||
String? _extractShortUrlId(String url) {
|
||||
try {
|
||||
@@ -156,7 +168,7 @@ class NaverMapParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// GraphQL API로 식당 정보 가져오기
|
||||
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
|
||||
String placeId, {
|
||||
@@ -168,35 +180,41 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
||||
}
|
||||
|
||||
|
||||
// 네이버 지도 URL 구성
|
||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
|
||||
|
||||
// Step 1: URL 자체로 검색 (가장 신뢰할 수 있는 방법)
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 방지
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _shortDelayMillis),
|
||||
); // 429 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeUrl,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
// place ID가 포함된 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
if (result.link.contains(placeId)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -205,21 +223,25 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _longDelayMillis)); // 더 긴 지연
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _longDelayMillis),
|
||||
); // 더 긴 지연
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeId,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -227,7 +249,7 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -236,12 +258,11 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -250,7 +271,7 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
@@ -262,7 +283,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.placeDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// places 응답 처리 (배열일 수도 있음)
|
||||
final placesData = response['data']?['places'];
|
||||
if (placesData != null) {
|
||||
@@ -277,7 +298,7 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
@@ -288,7 +309,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||
final nxPlacesData = response['data']?['nxPlaces'];
|
||||
if (nxPlacesData != null) {
|
||||
@@ -303,21 +324,28 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing');
|
||||
debugPrint(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
}
|
||||
return await _fallbackToHtmlParsing(placeId);
|
||||
}
|
||||
|
||||
|
||||
/// 검색 결과를 데이터 맵으로 변환
|
||||
Map<String, dynamic> _convertSearchResultToData(NaverLocalSearchResult item) {
|
||||
// 카테고리 파싱
|
||||
final categoryParts = item.category.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = item.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: category;
|
||||
|
||||
return {
|
||||
'name': item.title,
|
||||
'category': category,
|
||||
@@ -326,25 +354,32 @@ class NaverMapParser {
|
||||
'roadAddress': item.roadAddress,
|
||||
'phone': item.telephone,
|
||||
'description': item.description.isNotEmpty ? item.description : null,
|
||||
'latitude': item.mapy != null ? item.mapy! / _coordinateConversionFactor : _defaultLatitude,
|
||||
'longitude': item.mapx != null ? item.mapx! / _coordinateConversionFactor : _defaultLongitude,
|
||||
'businessHours': null, // Search API에서는 영업시간 정보 제공 안 함
|
||||
'latitude': item.mapy != null
|
||||
? item.mapy! / _coordinateConversionFactor
|
||||
: _defaultLatitude,
|
||||
'longitude': item.mapx != null
|
||||
? item.mapx! / _coordinateConversionFactor
|
||||
: _defaultLongitude,
|
||||
'businessHours': null, // Search API에서는 영업시간 정보 제공 안 함
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// GraphQL 응답에서 데이터 추출
|
||||
Map<String, dynamic> _extractPlaceData(Map<String, dynamic> placeData) {
|
||||
// 카테고리 파싱
|
||||
final String? fullCategory = placeData['category'];
|
||||
String? category;
|
||||
String? subCategory;
|
||||
|
||||
|
||||
if (fullCategory != null) {
|
||||
final categoryParts = fullCategory.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = fullCategory
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
category = categoryParts.isNotEmpty ? categoryParts.first : null;
|
||||
subCategory = categoryParts.length > 1 ? categoryParts.last : null;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
'name': placeData['name'],
|
||||
'category': category,
|
||||
@@ -360,26 +395,24 @@ class NaverMapParser {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// HTML 파싱으로 fallback
|
||||
Future<Map<String, dynamic>> _fallbackToHtmlParsing(String placeId) async {
|
||||
try {
|
||||
final finalUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
final String html = await _apiClient.fetchMapPageHtml(finalUrl);
|
||||
final document = html_parser.parse(html);
|
||||
|
||||
|
||||
return _htmlParser.parseRestaurantInfo(document);
|
||||
} catch (e) {
|
||||
// 429 에러인 경우 RateLimitException으로 변환
|
||||
if (e.toString().contains('429')) {
|
||||
throw RateLimitException(
|
||||
originalError: e,
|
||||
);
|
||||
throw RateLimitException(originalError: e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Restaurant 객체 생성
|
||||
Restaurant _createRestaurant(
|
||||
Map<String, dynamic> data,
|
||||
@@ -397,25 +430,33 @@ class NaverMapParser {
|
||||
final double? latitude = data['latitude'];
|
||||
final double? longitude = data['longitude'];
|
||||
final String? businessHours = data['businessHours'];
|
||||
|
||||
|
||||
// 카테고리 정규화
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(rawCategory, rawSubCategory);
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
rawCategory,
|
||||
rawSubCategory,
|
||||
);
|
||||
final String finalSubCategory = rawSubCategory ?? rawCategory;
|
||||
|
||||
|
||||
// 좌표가 없는 경우 기본값 설정
|
||||
final double finalLatitude = latitude ?? _defaultLatitude;
|
||||
final double finalLongitude = longitude ?? _defaultLongitude;
|
||||
|
||||
|
||||
// 주소가 비어있는 경우 처리
|
||||
final String finalRoadAddress = roadAddress.isNotEmpty ? roadAddress : '주소 정보를 가져올 수 없습니다';
|
||||
final String finalJibunAddress = jibunAddress.isNotEmpty ? jibunAddress : '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
final String finalRoadAddress = roadAddress.isNotEmpty
|
||||
? roadAddress
|
||||
: '주소 정보를 가져올 수 없습니다';
|
||||
final String finalJibunAddress = jibunAddress.isNotEmpty
|
||||
? jibunAddress
|
||||
: '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
return Restaurant(
|
||||
id: _uuid.v4(),
|
||||
name: name,
|
||||
category: normalizedCategory,
|
||||
subCategory: finalSubCategory,
|
||||
description: description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
description:
|
||||
description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: finalRoadAddress,
|
||||
jibunAddress: finalJibunAddress,
|
||||
@@ -432,7 +473,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 기본 정보로 Restaurant 생성 (Fallback)
|
||||
Restaurant _createFallbackRestaurant(String placeId, String url) {
|
||||
return Restaurant(
|
||||
@@ -457,7 +498,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 단축 URL을 위한 향상된 파싱 메서드
|
||||
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
|
||||
Future<Restaurant> _parseWithLocalSearch(
|
||||
@@ -469,16 +510,16 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
|
||||
// 1. 한글 텍스트 추출
|
||||
final koreanData = await _apiClient.fetchKoreanTextsFromPcmap(placeId);
|
||||
|
||||
|
||||
if (koreanData['success'] != true || koreanData['koreanTexts'] == null) {
|
||||
throw NaverMapParseException('한글 텍스트 추출 실패');
|
||||
}
|
||||
|
||||
|
||||
final koreanTexts = koreanData['koreanTexts'] as List<dynamic>;
|
||||
|
||||
|
||||
// 상호명 우선순위 결정
|
||||
String searchQuery = '';
|
||||
if (koreanData['jsonLdName'] != null) {
|
||||
@@ -499,25 +540,27 @@ class NaverMapParser {
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
|
||||
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _shortDelayMillis),
|
||||
); // 429 에러 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: searchQuery,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: 20, // 더 많은 결과 검색
|
||||
display: 20, // 더 많은 결과 검색
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
|
||||
}
|
||||
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
@@ -530,10 +573,10 @@ class NaverMapParser {
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
}
|
||||
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
|
||||
|
||||
// 1차: Place ID가 정확히 일치하는 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
final extractedId = result.extractPlaceId();
|
||||
@@ -545,18 +588,19 @@ class NaverMapParser {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2차: 상호명이 유사한 결과 찾기
|
||||
if (bestMatch == null) {
|
||||
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
|
||||
String? exactName = koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
String? exactName =
|
||||
koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
if (exactName != null) {
|
||||
for (final result in searchResults) {
|
||||
// 상호명 완전 일치 또는 포함 관계 확인
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
@@ -567,15 +611,19 @@ class NaverMapParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
|
||||
if (bestMatch == null && userLatitude != null && userLongitude != null) {
|
||||
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
|
||||
bestMatch = _findNearestResult(
|
||||
searchResults,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 최종: 첫 번째 결과 사용
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
@@ -583,10 +631,10 @@ class NaverMapParser {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. Restaurant 객체 생성
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
|
||||
// 추가 정보 보완
|
||||
return restaurant.copyWith(
|
||||
naverPlaceId: placeId,
|
||||
@@ -595,7 +643,7 @@ class NaverMapParser {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 가장 가까운 결과 찾기 (거리 기반)
|
||||
NaverLocalSearchResult? _findNearestResult(
|
||||
List<NaverLocalSearchResult> results,
|
||||
@@ -604,56 +652,66 @@ class NaverMapParser {
|
||||
) {
|
||||
NaverLocalSearchResult? nearest;
|
||||
double minDistance = double.infinity;
|
||||
|
||||
|
||||
for (final result in results) {
|
||||
if (result.mapy != null && result.mapx != null) {
|
||||
// 네이버 좌표를 일반 좌표로 변환
|
||||
final lat = result.mapy! / _coordinateConversionFactor;
|
||||
final lng = result.mapx! / _coordinateConversionFactor;
|
||||
|
||||
|
||||
// 거리 계산
|
||||
final distance = _calculateDistance(userLat, userLng, lat, lng);
|
||||
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (kDebugMode && nearest != null) {
|
||||
debugPrint('가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)');
|
||||
debugPrint(
|
||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
|
||||
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
|
||||
///
|
||||
///
|
||||
/// 반환값: 킬로미터 단위의 거리
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double _calculateDistance(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const double earthRadius = 6371.0; // 지구 반지름 (km)
|
||||
|
||||
|
||||
// 라디안으로 변환
|
||||
final double lat1Rad = lat1 * (3.141592653589793 / 180.0);
|
||||
final double lon1Rad = lon1 * (3.141592653589793 / 180.0);
|
||||
final double lat2Rad = lat2 * (3.141592653589793 / 180.0);
|
||||
final double lon2Rad = lon2 * (3.141592653589793 / 180.0);
|
||||
|
||||
|
||||
// 위도와 경도의 차이
|
||||
final double dLat = lat2Rad - lat1Rad;
|
||||
final double dLon = lon2Rad - lon1Rad;
|
||||
|
||||
|
||||
// Haversine 공식
|
||||
final double a = (sin(dLat / 2) * sin(dLat / 2)) +
|
||||
final double a =
|
||||
(sin(dLat / 2) * sin(dLat / 2)) +
|
||||
(cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2) * sin(dLon / 2));
|
||||
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
_apiClient.dispose();
|
||||
}
|
||||
}
|
||||
@@ -661,9 +719,9 @@ class NaverMapParser {
|
||||
/// 네이버 지도 파싱 예외
|
||||
class NaverMapParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
|
||||
NaverMapParseException(this.message);
|
||||
|
||||
|
||||
@override
|
||||
String toString() => 'NaverMapParseException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,26 @@ import '../../../core/errors/network_exceptions.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
|
||||
/// 네이버 검색 서비스
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다.
|
||||
class NaverSearchService {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
|
||||
// 성능 최적화를 위한 정규식 캐싱
|
||||
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
||||
|
||||
NaverSearchService({
|
||||
NaverApiClient? apiClient,
|
||||
NaverMapParser? mapParser,
|
||||
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
|
||||
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
|
||||
///
|
||||
///
|
||||
/// [url] 네이버 지도 URL 또는 단축 URL
|
||||
///
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NaverMapParseException] URL 파싱 실패 시
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
@@ -39,15 +37,12 @@ class NaverSearchService {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 정보를 가져올 수 없습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 정보를 가져올 수 없습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 키워드로 주변 식당 검색
|
||||
///
|
||||
///
|
||||
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<Restaurant>> searchNearbyRestaurants({
|
||||
required String query,
|
||||
@@ -64,7 +59,7 @@ class NaverSearchService {
|
||||
display: maxResults,
|
||||
sort: sort,
|
||||
);
|
||||
|
||||
|
||||
return searchResults
|
||||
.map((result) => result.toRestaurant(id: _uuid.v4()))
|
||||
.toList();
|
||||
@@ -72,15 +67,12 @@ class NaverSearchService {
|
||||
if (e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 검색에 실패했습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 검색에 실패했습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름으로 상세 정보 검색
|
||||
///
|
||||
///
|
||||
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
|
||||
Future<Restaurant?> searchRestaurantDetails({
|
||||
required String name,
|
||||
@@ -98,7 +90,7 @@ class NaverSearchService {
|
||||
query = '${addressParts[0]} ${addressParts[1]} $name';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
@@ -106,37 +98,38 @@ class NaverSearchService {
|
||||
display: 5,
|
||||
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함)
|
||||
final bestMatch = _findBestMatch(
|
||||
name,
|
||||
name,
|
||||
searchResults,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
|
||||
|
||||
if (bestMatch != null) {
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
|
||||
// 네이버 지도 URL이 있으면 상세 정보 파싱 시도
|
||||
if (restaurant.naverUrl != null) {
|
||||
try {
|
||||
final detailedRestaurant = await _mapParser.parseRestaurantFromUrl(
|
||||
restaurant.naverUrl!,
|
||||
);
|
||||
|
||||
|
||||
// 기존 정보와 병합
|
||||
return Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
description:
|
||||
detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
@@ -146,9 +139,11 @@ class NaverSearchService {
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverPlaceId:
|
||||
detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
businessHours:
|
||||
detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
@@ -159,10 +154,10 @@ class NaverSearchService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is NetworkException) {
|
||||
@@ -174,7 +169,7 @@ class NaverSearchService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 가장 유사한 검색 결과 찾기
|
||||
NaverLocalSearchResult? _findBestMatch(
|
||||
String targetName,
|
||||
@@ -184,30 +179,32 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
|
||||
// 정확히 일치하는 결과 우선
|
||||
final exactMatch = results.firstWhere(
|
||||
(result) => result.title.toLowerCase() == targetName.toLowerCase(),
|
||||
orElse: () => results.first,
|
||||
);
|
||||
|
||||
|
||||
if (exactMatch.title.toLowerCase() == targetName.toLowerCase()) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
|
||||
// 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택
|
||||
// TODO: 네이버 좌표계(mapx, mapy)를 WGS84 좌표계로 변환하는 로직 필요
|
||||
// 현재는 네이버 API가 좌표 기반 정렬을 지원하므로 첫 번째 결과 사용
|
||||
if ((address == null || address.isEmpty) && latitude != null && longitude != null) {
|
||||
if ((address == null || address.isEmpty) &&
|
||||
latitude != null &&
|
||||
longitude != null) {
|
||||
// 네이버 API는 coordinate 파라미터로 좌표 기반 정렬을 지원
|
||||
// searchRestaurants에서 이미 가까운 순으로 정렬되어 반환됨
|
||||
return results.first;
|
||||
}
|
||||
|
||||
|
||||
// 유사도 계산 (간단한 버전)
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
double bestScore = 0.0;
|
||||
|
||||
|
||||
for (final result in results) {
|
||||
final score = _calculateSimilarity(targetName, result.title);
|
||||
if (score > bestScore) {
|
||||
@@ -215,44 +212,44 @@ class NaverSearchService {
|
||||
bestMatch = result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 유사도가 너무 낮으면 null 반환
|
||||
if (bestScore < 0.5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return bestMatch ?? results.first;
|
||||
}
|
||||
|
||||
|
||||
/// 문자열 유사도 계산 (Jaccard 유사도)
|
||||
double _calculateSimilarity(String str1, String str2) {
|
||||
final s1 = str1.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
final s2 = str2.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
|
||||
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
|
||||
// 포함 관계 확인
|
||||
if (s1.contains(s2) || s2.contains(s1)) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
|
||||
// 문자 집합으로 변환
|
||||
final set1 = s1.split('').toSet();
|
||||
final set2 = s2.split('').toSet();
|
||||
|
||||
|
||||
// Jaccard 유사도 계산
|
||||
final intersection = set1.intersection(set2).length;
|
||||
final union = set1.union(set2).length;
|
||||
|
||||
|
||||
return union > 0 ? intersection / union : 0.0;
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_apiClient.dispose();
|
||||
_mapParser.dispose();
|
||||
}
|
||||
|
||||
|
||||
// 테스트를 위한 내부 메서드 접근
|
||||
@visibleForTesting
|
||||
NaverLocalSearchResult? findBestMatchForTesting(
|
||||
@@ -263,16 +260,16 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
return _findBestMatch(
|
||||
targetName,
|
||||
targetName,
|
||||
results,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
double calculateSimilarityForTesting(String str1, String str2) {
|
||||
return _calculateSimilarity(str1, str2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user