import 'dart:math'; import 'package:dio/dio.dart'; import 'package:html/parser.dart' as html_parser; import 'package:uuid/uuid.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:flutter/foundation.dart'; import '../../api/naver_api_client.dart'; import '../../api/naver/naver_local_search_api.dart'; import '../../../core/errors/network_exceptions.dart'; import 'naver_html_parser.dart'; import '../../api/naver/naver_graphql_queries.dart'; import '../../../core/utils/category_mapper.dart'; /// 네이버 지도 URL 파서 /// 네이버 지도 URL에서 식당 정보를 추출합니다. /// NaverApiClient를 사용하여 네트워크 통신을 처리합니다. 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 _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}) : _apiClient = apiClient ?? NaverApiClient(); /// 네이버 지도 URL에서 식당 정보를 파싱합니다. /// /// 지원하는 URL 형식: /// - https://map.naver.com/p/restaurant/1234567890 /// - https://naver.me/abcdefgh /// /// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때 /// 가장 가까운 위치의 식당을 선택합니다. Future parseRestaurantFromUrl( String url, { double? userLatitude, double? userLongitude, }) async { 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) { // 짧은 URL에서 직접 ID 추출 시도 final shortUrlId = _extractShortUrlId(url); if (shortUrlId != null) { if (kDebugMode) { 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); if (kDebugMode) { debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}'); } return restaurant; } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e'); } // 실패 시 기본 파싱으로 계속 진행 } } // GraphQL API로 식당 정보 가져오기 (기본 플로우) final restaurantData = await _fetchRestaurantFromGraphQL( placeId, userLatitude: userLatitude, userLongitude: userLongitude, ); return _createRestaurant(restaurantData, placeId, finalUrl); } catch (e) { if (e is NaverMapParseException) { rethrow; } if (e is RateLimitException) { rethrow; } if (e is NetworkException) { throw NaverMapParseException('네트워크 오류: ${e.message}'); } throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e'); } } /// URL이 유효한 네이버 지도 URL인지 확인 bool _isValidNaverUrl(String url) { try { final Uri uri = Uri.parse(url); return uri.host.contains('naver.com') || uri.host.contains('naver.me'); } catch (e) { 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 { final match = _shortUrlRegex.firstMatch(url); return match?.group(1); } catch (e) { return null; } } /// GraphQL API로 식당 정보 가져오기 Future> _fetchRestaurantFromGraphQL( String placeId, { double? userLatitude, double? userLongitude, }) async { // 심플한 접근: URL로 직접 검색 try { 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 방지 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}'); } return _convertSearchResultToData(result); } } // 정확한 매칭이 없으면 첫 번째 결과 사용 if (kDebugMode) { debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}'); } return _convertSearchResultToData(searchResults.first); } } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: URL 검색 실패 - $e'); } } // Step 2: Place ID로 검색 try { 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}'); } return _convertSearchResultToData(searchResults.first); } } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: Place ID 검색 실패 - $e'); } // 429 에러인 경우 즉시 예외 발생 if (e is DioException && e.response?.statusCode == 429) { throw RateLimitException( retryAfter: e.response?.headers['retry-after']?.firstOrNull, originalError: e, ); } } } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: URL 기반 검색 실패 - $e'); } // 429 에러인 경우 즉시 예외 발생 if (e is DioException && e.response?.statusCode == 429) { throw RateLimitException( retryAfter: e.response?.headers['retry-after']?.firstOrNull, originalError: e, ); } } // 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도) // 첫 번째 시도: places 쿼리 try { if (kDebugMode) { debugPrint('NaverMapParser: Trying places query...'); } final response = await _apiClient.fetchGraphQL( operationName: 'getPlaceDetail', variables: {'id': placeId}, query: NaverGraphQLQueries.placeDetailQuery, ); // places 응답 처리 (배열일 수도 있음) final placesData = response['data']?['places']; if (placesData != null) { if (placesData is List && placesData.isNotEmpty) { return _extractPlaceData(placesData.first as Map); } else if (placesData is Map) { return _extractPlaceData(placesData as Map); } } } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: places query failed - $e'); } } // 두 번째 시도: nxPlaces 쿼리 try { if (kDebugMode) { debugPrint('NaverMapParser: Trying nxPlaces query...'); } final response = await _apiClient.fetchGraphQL( operationName: 'getPlaceDetail', variables: {'id': placeId}, query: NaverGraphQLQueries.nxPlaceDetailQuery, ); // nxPlaces 응답 처리 (배열일 수도 있음) final nxPlacesData = response['data']?['nxPlaces']; if (nxPlacesData != null) { if (nxPlacesData is List && nxPlacesData.isNotEmpty) { return _extractPlaceData(nxPlacesData.first as Map); } else if (nxPlacesData is Map) { return _extractPlaceData(nxPlacesData as Map); } } } catch (e) { if (kDebugMode) { debugPrint('NaverMapParser: nxPlaces query failed - $e'); } } // 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback if (kDebugMode) { debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing'); } return await _fallbackToHtmlParsing(placeId); } /// 검색 결과를 데이터 맵으로 변환 Map _convertSearchResultToData(NaverLocalSearchResult item) { // 카테고리 파싱 final categoryParts = item.category.split('>').map((s) => s.trim()).toList(); final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점'; final subCategory = categoryParts.length > 1 ? categoryParts.last : category; return { 'name': item.title, 'category': category, 'subCategory': subCategory, 'address': item.address, '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에서는 영업시간 정보 제공 안 함 }; } /// GraphQL 응답에서 데이터 추출 Map _extractPlaceData(Map placeData) { // 카테고리 파싱 final String? fullCategory = placeData['category']; String? category; String? subCategory; if (fullCategory != null) { 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, 'subCategory': subCategory, 'address': placeData['address'], 'roadAddress': placeData['roadAddress'], 'phone': placeData['phone'] ?? placeData['virtualPhone'], 'description': placeData['description'], 'latitude': placeData['location']?['lat'], 'longitude': placeData['location']?['lng'], 'businessHours': placeData['businessHours']?.isNotEmpty == true ? placeData['businessHours'][0]['description'] : null, }; } /// HTML 파싱으로 fallback Future> _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, ); } rethrow; } } /// Restaurant 객체 생성 Restaurant _createRestaurant( Map data, String placeId, String url, ) { // 데이터 추출 및 기본값 처리 final String name = data['name'] ?? '네이버 지도 장소 #$placeId'; final String rawCategory = data['category'] ?? '음식점'; final String? rawSubCategory = data['subCategory']; final String? description = data['description']; final String? phoneNumber = data['phone']; final String roadAddress = data['roadAddress'] ?? ''; final String jibunAddress = data['address'] ?? ''; final double? latitude = data['latitude']; final double? longitude = data['longitude']; final String? businessHours = data['businessHours']; // 카테고리 정규화 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 : '주소 정보를 가져올 수 없습니다'; return Restaurant( id: _uuid.v4(), name: name, category: normalizedCategory, subCategory: finalSubCategory, description: description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.', phoneNumber: phoneNumber, roadAddress: finalRoadAddress, jibunAddress: finalJibunAddress, latitude: finalLatitude, longitude: finalLongitude, lastVisitDate: null, source: DataSource.NAVER, createdAt: DateTime.now(), updatedAt: DateTime.now(), naverPlaceId: placeId, naverUrl: url, businessHours: businessHours, lastVisited: null, visitCount: 0, ); } /// 기본 정보로 Restaurant 생성 (Fallback) Restaurant _createFallbackRestaurant(String placeId, String url) { return Restaurant( id: _uuid.v4(), name: '네이버 지도 장소 #$placeId', category: '음식점', subCategory: '음식점', description: '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.', phoneNumber: null, roadAddress: '주소 정보를 가져올 수 없습니다', jibunAddress: '주소 정보를 가져올 수 없습니다', latitude: _defaultLatitude, longitude: _defaultLongitude, lastVisitDate: null, source: DataSource.NAVER, createdAt: DateTime.now(), updatedAt: DateTime.now(), naverPlaceId: placeId, naverUrl: url, businessHours: null, lastVisited: null, visitCount: 0, ); } /// 단축 URL을 위한 향상된 파싱 메서드 /// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득 Future _parseWithLocalSearch( String placeId, String finalUrl, double? userLatitude, double? userLongitude, ) async { 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; // 상호명 우선순위 결정 String searchQuery = ''; if (koreanData['jsonLdName'] != null) { searchQuery = koreanData['jsonLdName'] as String; if (kDebugMode) { debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery'); } } else if (koreanData['apolloStateName'] != null) { searchQuery = koreanData['apolloStateName'] as String; if (kDebugMode) { debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery'); } } else if (koreanTexts.isNotEmpty) { searchQuery = koreanTexts.first as String; if (kDebugMode) { debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery'); } } else { throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다'); } // 2. 로컬 검색 API 호출 if (kDebugMode) { debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"'); } await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지 final searchResults = await _apiClient.searchLocal( query: searchQuery, latitude: userLatitude, longitude: userLongitude, display: 20, // 더 많은 결과 검색 ); if (searchResults.isEmpty) { throw NaverMapParseException('검색 결과가 없습니다: $searchQuery'); } // 디버깅: 검색 결과 Place ID 분석 if (kDebugMode) { debugPrint('=== 로컬 검색 결과 Place ID 분석 ==='); for (int i = 0; i < searchResults.length; i++) { final result = searchResults[i]; final extractedId = result.extractPlaceId(); debugPrint('[$i] ${result.title}'); debugPrint(' 링크: ${result.link}'); debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)'); } debugPrint('====================================='); } // 3. 최적의 결과 선택 - 3단계 매칭 알고리즘 NaverLocalSearchResult? bestMatch; // 1차: Place ID가 정확히 일치하는 결과 찾기 for (final result in searchResults) { final extractedId = result.extractPlaceId(); if (extractedId == placeId) { bestMatch = result; if (kDebugMode) { debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}'); } break; } } // 2차: 상호명이 유사한 결과 찾기 if (bestMatch == null) { // JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용 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) || exactName.contains(result.title)) { bestMatch = result; if (kDebugMode) { debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}'); } break; } } } } // 3차: 거리 기반 선택 (사용자 위치가 있는 경우) if (bestMatch == null && userLatitude != null && userLongitude != null) { bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude); if (bestMatch != null && kDebugMode) { debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}'); } } // 최종: 첫 번째 결과 사용 if (bestMatch == null) { bestMatch = searchResults.first; if (kDebugMode) { debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}'); } } // 4. Restaurant 객체 생성 final restaurant = bestMatch.toRestaurant(id: _uuid.v4()); // 추가 정보 보완 return restaurant.copyWith( naverPlaceId: placeId, naverUrl: finalUrl, source: DataSource.NAVER, updatedAt: DateTime.now(), ); } /// 가장 가까운 결과 찾기 (거리 기반) NaverLocalSearchResult? _findNearestResult( List results, double userLat, double userLng, ) { 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)'); } return nearest; } /// 두 지점 간의 거리 계산 (Haversine 공식 사용) /// /// 반환값: 킬로미터 단위의 거리 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)) + (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() { _apiClient.dispose(); } } /// 네이버 지도 파싱 예외 class NaverMapParseException implements Exception { final String message; NaverMapParseException(this.message); @override String toString() => 'NaverMapParseException: $message'; }