745 lines
25 KiB
Dart
745 lines
25 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:html/parser.dart' as html_parser;
|
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../../api/naver/naver_graphql_queries.dart';
|
|
import '../../api/naver/naver_local_search_api.dart';
|
|
import '../../api/naver_api_client.dart';
|
|
import '../../../core/errors/network_exceptions.dart';
|
|
import '../../../core/utils/category_mapper.dart';
|
|
import 'naver_html_parser.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)/|/place/)(\d+)',
|
|
);
|
|
static final RegExp _pinIdRegex = RegExp(r'pinId["=](\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();
|
|
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(
|
|
String url, {
|
|
double? userLatitude,
|
|
double? userLongitude,
|
|
}) async {
|
|
if (_isDisposed) {
|
|
throw NaverMapParseException('이미 dispose된 파서입니다');
|
|
}
|
|
try {
|
|
AppLogger.debug('[naver_url] 원본 URL 수신: $url');
|
|
|
|
// URL 유효성 검증
|
|
if (!_isValidNaverUrl(url)) {
|
|
throw NaverMapParseException('유효하지 않은 네이버 지도 URL입니다: $url');
|
|
}
|
|
|
|
// 짧은 URL인 경우 리다이렉트 처리
|
|
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
|
|
|
AppLogger.debug('[naver_url] resolveShortUrl 결과: $finalUrl');
|
|
|
|
// Place ID 추출 (10자리 숫자)
|
|
final String? placeId = _extractPlaceId(finalUrl);
|
|
if (placeId == null) {
|
|
// 짧은 URL에서 직접 ID 추출 시도
|
|
final shortUrlId = _extractShortUrlId(url);
|
|
if (shortUrlId != null) {
|
|
AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId');
|
|
return _createFallbackRestaurant(shortUrlId, url);
|
|
}
|
|
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
|
}
|
|
AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId');
|
|
|
|
// 단축 URL인 경우 특별 처리
|
|
final isShortUrl = url.contains('naver.me');
|
|
|
|
if (isShortUrl) {
|
|
AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
|
|
|
try {
|
|
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
|
final restaurant = await _parseWithLocalSearch(
|
|
placeId,
|
|
finalUrl,
|
|
userLatitude,
|
|
userLongitude,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] LocalSearch 파싱 성공: '
|
|
'name=${restaurant.name}, road=${restaurant.roadAddress}',
|
|
);
|
|
return restaurant;
|
|
} catch (e, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
// 실패 시 기본 파싱으로 계속 진행
|
|
}
|
|
}
|
|
|
|
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
|
|
final restaurantData = await _fetchRestaurantFromGraphQL(
|
|
placeId,
|
|
userLatitude: userLatitude,
|
|
userLongitude: userLongitude,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] GraphQL/검색 파싱 결과 요약: '
|
|
'name=${restaurantData['name']}, '
|
|
'road=${restaurantData['roadAddress']}, '
|
|
'phone=${restaurantData['phone']}',
|
|
);
|
|
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);
|
|
if (match != null) return match.group(1);
|
|
|
|
// 핀 공유 형식: pinId="1234567890" 또는 pinId=1234567890
|
|
final pinMatch = _pinIdRegex.firstMatch(url);
|
|
return pinMatch?.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 {
|
|
AppLogger.debug('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,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, '
|
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
|
);
|
|
|
|
if (searchResults.isNotEmpty) {
|
|
// place ID가 포함된 결과 찾기
|
|
for (final result in searchResults) {
|
|
if (result.link.contains(placeId)) {
|
|
AppLogger.debug(
|
|
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
|
);
|
|
return _convertSearchResultToData(result);
|
|
}
|
|
}
|
|
|
|
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
|
AppLogger.debug(
|
|
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
|
);
|
|
return _convertSearchResultToData(searchResults.first);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: URL 검색 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, '
|
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
|
);
|
|
|
|
if (searchResults.isNotEmpty) {
|
|
AppLogger.debug(
|
|
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
|
);
|
|
return _convertSearchResultToData(searchResults.first);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: Place ID 검색 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
|
|
// 429 에러인 경우 즉시 예외 발생
|
|
if (e is DioException && e.response?.statusCode == 429) {
|
|
throw RateLimitException(
|
|
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
|
|
originalError: e,
|
|
);
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: URL 기반 검색 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
|
|
// 429 에러인 경우 즉시 예외 발생
|
|
if (e is DioException && e.response?.statusCode == 429) {
|
|
throw RateLimitException(
|
|
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
|
|
originalError: e,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
|
// 첫 번째 시도: places 쿼리
|
|
try {
|
|
AppLogger.debug('NaverMapParser: Trying places query...');
|
|
final response = await _apiClient.fetchGraphQL(
|
|
operationName: 'getPlaceDetail',
|
|
variables: {'id': placeId},
|
|
query: NaverGraphQLQueries.placeDetailQuery,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] places query 응답 keys: ${response.keys.toList()}',
|
|
);
|
|
|
|
// 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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: places query failed - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
|
|
// 두 번째 시도: nxPlaces 쿼리
|
|
try {
|
|
AppLogger.debug('NaverMapParser: Trying nxPlaces query...');
|
|
final response = await _apiClient.fetchGraphQL(
|
|
operationName: 'getPlaceDetail',
|
|
variables: {'id': placeId},
|
|
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
|
);
|
|
AppLogger.debug(
|
|
'[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}',
|
|
);
|
|
|
|
// 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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverMapParser: nxPlaces query failed - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
|
|
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
|
AppLogger.debug(
|
|
'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) {
|
|
AppLogger.debug('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) {
|
|
AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
|
}
|
|
} else if (koreanData['apolloStateName'] != null) {
|
|
searchQuery = koreanData['apolloStateName'] as String;
|
|
if (kDebugMode) {
|
|
AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
|
}
|
|
} else if (koreanTexts.isNotEmpty) {
|
|
searchQuery = koreanTexts.first as String;
|
|
if (kDebugMode) {
|
|
AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
|
}
|
|
} else {
|
|
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
|
}
|
|
|
|
// 2. 로컬 검색 API 호출
|
|
if (kDebugMode) {
|
|
AppLogger.debug('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) {
|
|
AppLogger.debug('=== 로컬 검색 결과 Place ID 분석 ===');
|
|
for (int i = 0; i < searchResults.length; i++) {
|
|
final result = searchResults[i];
|
|
final extractedId = result.extractPlaceId();
|
|
AppLogger.debug('[$i] ${result.title}');
|
|
AppLogger.debug(' 링크: ${result.link}');
|
|
AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
|
}
|
|
AppLogger.debug('=====================================');
|
|
}
|
|
|
|
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
|
NaverLocalSearchResult? bestMatch;
|
|
|
|
// 1차: Place ID가 정확히 일치하는 결과 찾기
|
|
for (final result in searchResults) {
|
|
final extractedId = result.extractPlaceId();
|
|
if (extractedId == placeId) {
|
|
bestMatch = result;
|
|
if (kDebugMode) {
|
|
AppLogger.debug('✅ 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) {
|
|
AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
|
|
if (bestMatch == null && userLatitude != null && userLongitude != null) {
|
|
bestMatch = _findNearestResult(
|
|
searchResults,
|
|
userLatitude,
|
|
userLongitude,
|
|
);
|
|
if (bestMatch != null && kDebugMode) {
|
|
AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
|
}
|
|
}
|
|
|
|
// 최종: 첫 번째 결과 사용
|
|
if (bestMatch == null) {
|
|
bestMatch = searchResults.first;
|
|
if (kDebugMode) {
|
|
AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${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) {
|
|
AppLogger.debug(
|
|
'가장 가까운 결과: ${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() {
|
|
if (_isDisposed) return;
|
|
_isDisposed = true;
|
|
_apiClient.dispose();
|
|
}
|
|
}
|
|
|
|
/// 네이버 지도 파싱 예외
|
|
class NaverMapParseException implements Exception {
|
|
final String message;
|
|
|
|
NaverMapParseException(this.message);
|
|
|
|
@override
|
|
String toString() => 'NaverMapParseException: $message';
|
|
}
|