feat(app): add manual entry and sharing flows

This commit is contained in:
JiWoong Sul
2025-11-19 16:36:39 +09:00
parent 5ade584370
commit 947fe59486
110 changed files with 5937 additions and 3781 deletions

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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';
}
}

View File

@@ -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);
}
}
}