1. placeId 기반 중복 체크 제거 - 이전 대화에서 명확히 한 대로 placeId는 매칭에 사용하지 않음 2. 주소 기반 매칭 개선 - 주소가 있을 때만 주소 기반 중복 체크 수행 3. 위치 기반 매칭 추가 - 50m 이내 동일한 이름의 맛집 중복 체크 추가 4. 검색 결과 선택 로직 개선 - 주소가 없을 때 가장 가까운 거리의 업체 선택 5. 카테고리 필터 버그 수정 - 카테고리 표시명과 실제 값 불일치 문제 해결 - 부분 일치 및 정규화된 비교 지원 6. 빈 상태 메시지 개선 - 필터링 중일 때 적절한 안내 메시지 표시 - 필터 초기화 버튼 추가 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
292 lines
8.7 KiB
Dart
292 lines
8.7 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import '../../api/naver_api_client.dart';
|
|
import '../../api/naver/naver_local_search_api.dart';
|
|
import '../../../domain/entities/restaurant.dart';
|
|
import '../../../core/errors/network_exceptions.dart';
|
|
import '../../../core/utils/distance_calculator.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);
|
|
|
|
/// URL에서 식당 정보 가져오기
|
|
///
|
|
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
|
|
///
|
|
/// [url] 네이버 지도 URL 또는 단축 URL
|
|
///
|
|
/// Throws:
|
|
/// - [NaverMapParseException] URL 파싱 실패 시
|
|
/// - [NetworkException] 네트워크 오류 발생 시
|
|
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
|
try {
|
|
return await _mapParser.parseRestaurantFromUrl(url);
|
|
} catch (e) {
|
|
if (e is NaverMapParseException || e is NetworkException) {
|
|
rethrow;
|
|
}
|
|
throw ParseException(
|
|
message: '식당 정보를 가져올 수 없습니다: $e',
|
|
originalError: e,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 키워드로 주변 식당 검색
|
|
///
|
|
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
|
|
Future<List<Restaurant>> searchNearbyRestaurants({
|
|
required String query,
|
|
double? latitude,
|
|
double? longitude,
|
|
int maxResults = 20,
|
|
String sort = 'random', // random, comment
|
|
}) async {
|
|
try {
|
|
final searchResults = await _apiClient.searchLocal(
|
|
query: query,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
display: maxResults,
|
|
sort: sort,
|
|
);
|
|
|
|
return searchResults
|
|
.map((result) => result.toRestaurant(id: _uuid.v4()))
|
|
.toList();
|
|
} catch (e) {
|
|
if (e is NetworkException) {
|
|
rethrow;
|
|
}
|
|
throw ParseException(
|
|
message: '식당 검색에 실패했습니다: $e',
|
|
originalError: e,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 식당 이름으로 상세 정보 검색
|
|
///
|
|
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
|
|
Future<Restaurant?> searchRestaurantDetails({
|
|
required String name,
|
|
String? address,
|
|
double? latitude,
|
|
double? longitude,
|
|
}) async {
|
|
try {
|
|
// 검색어 구성
|
|
String query = name;
|
|
if (address != null && address.isNotEmpty) {
|
|
// 주소에서 시/구 정보 추출
|
|
final addressParts = address.split(' ');
|
|
if (addressParts.length >= 2) {
|
|
query = '${addressParts[0]} ${addressParts[1]} $name';
|
|
}
|
|
}
|
|
|
|
final searchResults = await _apiClient.searchLocal(
|
|
query: query,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
display: 5,
|
|
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
|
|
);
|
|
|
|
if (searchResults.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
// 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함)
|
|
final bestMatch = _findBestMatch(
|
|
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,
|
|
phoneNumber: restaurant.phoneNumber,
|
|
roadAddress: restaurant.roadAddress,
|
|
jibunAddress: restaurant.jibunAddress,
|
|
latitude: restaurant.latitude,
|
|
longitude: restaurant.longitude,
|
|
lastVisitDate: restaurant.lastVisitDate,
|
|
source: restaurant.source,
|
|
createdAt: restaurant.createdAt,
|
|
updatedAt: DateTime.now(),
|
|
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
|
naverUrl: restaurant.naverUrl,
|
|
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
|
lastVisited: restaurant.lastVisited,
|
|
visitCount: restaurant.visitCount,
|
|
);
|
|
} catch (e) {
|
|
// 상세 파싱 실패해도 기본 정보 반환
|
|
if (kDebugMode) {
|
|
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
return restaurant;
|
|
}
|
|
|
|
return null;
|
|
} catch (e) {
|
|
if (e is NetworkException) {
|
|
rethrow;
|
|
}
|
|
throw ParseException(
|
|
message: '식당 상세 정보 검색에 실패했습니다: $e',
|
|
originalError: e,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 가장 유사한 검색 결과 찾기
|
|
NaverLocalSearchResult? _findBestMatch(
|
|
String targetName,
|
|
List<NaverLocalSearchResult> results, {
|
|
double? latitude,
|
|
double? longitude,
|
|
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;
|
|
}
|
|
|
|
// 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택
|
|
if ((address == null || address.isEmpty) && latitude != null && longitude != null) {
|
|
NaverLocalSearchResult? closestResult;
|
|
double minDistance = double.infinity;
|
|
|
|
for (final result in results) {
|
|
final distance = DistanceCalculator.calculateDistance(
|
|
lat1: latitude,
|
|
lon1: longitude,
|
|
lat2: result.latitude,
|
|
lon2: result.longitude,
|
|
);
|
|
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
closestResult = result;
|
|
}
|
|
}
|
|
|
|
return closestResult ?? results.first;
|
|
}
|
|
|
|
// 유사도 계산 (간단한 버전)
|
|
NaverLocalSearchResult? bestMatch;
|
|
double bestScore = 0.0;
|
|
|
|
for (final result in results) {
|
|
final score = _calculateSimilarity(targetName, result.title);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
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(
|
|
String targetName,
|
|
List<NaverLocalSearchResult> results, {
|
|
double? latitude,
|
|
double? longitude,
|
|
String? address,
|
|
}) {
|
|
return _findBestMatch(
|
|
targetName,
|
|
results,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
address: address,
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
double calculateSimilarityForTesting(String str1, String str2) {
|
|
return _calculateSimilarity(str1, str2);
|
|
}
|
|
} |