feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
669
lib/data/datasources/remote/naver_map_parser.dart
Normal file
669
lib/data/datasources/remote/naver_map_parser.dart
Normal file
@@ -0,0 +1,669 @@
|
||||
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<Restaurant> 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<Map<String, dynamic>> _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<String, dynamic>);
|
||||
} else if (placesData is Map) {
|
||||
return _extractPlaceData(placesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} 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<String, dynamic>);
|
||||
} else if (nxPlacesData is Map) {
|
||||
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} 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<String, dynamic> _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<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();
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restaurant 객체 생성
|
||||
Restaurant _createRestaurant(
|
||||
Map<String, dynamic> 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<Restaurant> _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<dynamic>;
|
||||
|
||||
// 상호명 우선순위 결정
|
||||
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<NaverLocalSearchResult> 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';
|
||||
}
|
||||
Reference in New Issue
Block a user