Files
lunchpick/lib/data/datasources/remote/naver_search_service.dart
JiWoong Sul 2a01fa50c6 feat(app): finalize ad gated flows and weather
- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
2025-11-22 00:10:51 +09:00

286 lines
8.7 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:uuid/uuid.dart';
import '../../api/naver_api_client.dart';
import '../../api/naver/naver_local_search_api.dart';
import '../../../core/errors/network_exceptions.dart';
import '../../../domain/entities/restaurant.dart';
import 'naver_map_parser.dart';
import 'naver_url_processor.dart';
/// 네이버 검색 서비스
///
/// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다.
class NaverSearchService {
final NaverApiClient _apiClient;
final NaverMapParser _mapParser;
final NaverUrlProcessor _urlProcessor;
final Uuid _uuid = const Uuid();
// 성능 최적화를 위한 정규식 캐싱
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
NaverSearchService({
NaverApiClient? apiClient,
NaverMapParser? mapParser,
NaverUrlProcessor? urlProcessor,
}) : _apiClient = apiClient ?? NaverApiClient(),
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient),
_urlProcessor =
urlProcessor ??
NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser);
/// URL에서 식당 정보 가져오기
///
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
///
/// [url] 네이버 지도 URL 또는 단축 URL
///
/// Throws:
/// - [NaverMapParseException] URL 파싱 실패 시
/// - [NetworkException] 네트워크 오류 발생 시
Future<Restaurant> getRestaurantFromUrl(String url) async {
try {
return await _urlProcessor.processUrl(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) {
// 상세 파싱 실패해도 기본 정보 반환
AppLogger.debug(
'[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;
}
// 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택
// TODO: 네이버 좌표계(mapx, mapy)를 WGS84 좌표계로 변환하는 로직 필요
// 현재는 네이버 API가 좌표 기반 정렬을 지원하므로 첫 번째 결과 사용
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) {
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);
}
}