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:
126
lib/data/api/converters/naver_data_converter.dart
Normal file
126
lib/data/api/converters/naver_data_converter.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../naver/naver_local_search_api.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 데이터 변환기
|
||||
///
|
||||
/// 네이버 API 응답을 도메인 엔티티로 변환합니다.
|
||||
class NaverDataConverter {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// NaverLocalSearchResult를 Restaurant 엔티티로 변환
|
||||
static Restaurant fromLocalSearchResult(
|
||||
NaverLocalSearchResult result, {
|
||||
String? id,
|
||||
}) {
|
||||
// 좌표 변환 (네이버 지도 좌표계 -> WGS84)
|
||||
final convertedCoords = _convertNaverMapCoordinates(
|
||||
result.mapx,
|
||||
result.mapy,
|
||||
);
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final categoryParts = result.category.split('>').map((s) => s.trim()).toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: result.title,
|
||||
category: normalizedCategory,
|
||||
subCategory: subCategory,
|
||||
description: result.description.isNotEmpty ? result.description : null,
|
||||
phoneNumber: result.telephone.isNotEmpty ? result.telephone : null,
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
: result.address,
|
||||
jibunAddress: result.address,
|
||||
latitude: convertedCoords['latitude'] ?? 37.5665,
|
||||
longitude: convertedCoords['longitude'] ?? 126.9780,
|
||||
naverUrl: result.link.isNotEmpty ? result.link : null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
source: DataSource.NAVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// GraphQL 응답을 Restaurant 엔티티로 변환
|
||||
static Restaurant fromGraphQLResponse(
|
||||
Map<String, dynamic> placeData, {
|
||||
String? id,
|
||||
String? naverUrl,
|
||||
}) {
|
||||
// 영업시간 파싱
|
||||
String? businessHours;
|
||||
if (placeData['businessHours'] != null) {
|
||||
final hours = placeData['businessHours'] as List;
|
||||
businessHours = hours
|
||||
.where((h) => h['businessHours'] != null)
|
||||
.map((h) => h['businessHours'])
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 좌표 추출
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
if (placeData['location'] != null) {
|
||||
latitude = placeData['location']['latitude']?.toDouble();
|
||||
longitude = placeData['location']['longitude']?.toDouble();
|
||||
}
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final rawCategory = placeData['category'] ?? '음식점';
|
||||
final categoryParts = rawCategory.split('>').map((s) => s.trim()).toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: placeData['name'] ?? '이름 없음',
|
||||
category: normalizedCategory,
|
||||
subCategory: subCategory,
|
||||
description: placeData['description'],
|
||||
phoneNumber: placeData['phone'],
|
||||
roadAddress: placeData['address']?['roadAddress'] ?? '',
|
||||
jibunAddress: placeData['address']?['jibunAddress'] ?? '',
|
||||
latitude: latitude ?? 37.5665,
|
||||
longitude: longitude ?? 126.9780,
|
||||
businessHours: businessHours,
|
||||
naverUrl: naverUrl,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
source: DataSource.NAVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// 네이버 지도 좌표를 WGS84로 변환
|
||||
static Map<String, double?> _convertNaverMapCoordinates(
|
||||
double? mapx,
|
||||
double? mapy,
|
||||
) {
|
||||
if (mapx == null || mapy == null) {
|
||||
return {'latitude': null, 'longitude': null};
|
||||
}
|
||||
|
||||
// 네이버 지도 좌표계는 KATEC을 사용
|
||||
// 간단한 변환 공식 (정확도는 떨어지지만 실용적)
|
||||
// 실제로는 더 정교한 변환이 필요할 수 있음
|
||||
final longitude = mapx / 10000000.0;
|
||||
final latitude = mapy / 10000000.0;
|
||||
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
167
lib/data/api/naver/naver_graphql_api.dart
Normal file
167
lib/data/api/naver/naver_graphql_api.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 GraphQL API 클라이언트
|
||||
///
|
||||
/// 네이버 지도의 GraphQL API를 호출하여 상세 정보를 가져옵니다.
|
||||
class NaverGraphQLApi {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
static const String _graphqlEndpoint = 'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
NaverGraphQLApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// GraphQL 쿼리 실행
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required String query,
|
||||
Map<String, dynamic>? variables,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _networkClient.post<Map<String, dynamic>>(
|
||||
_graphqlEndpoint,
|
||||
data: {
|
||||
'operationName': operationName,
|
||||
'query': query,
|
||||
'variables': variables ?? {},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
'Origin': 'https://map.naver.com',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchGraphQL error: $e');
|
||||
throw ServerException(
|
||||
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 장소 상세 정보 가져오기 (한국어 텍스트)
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
const query = '''
|
||||
query getKoreanTexts(\$id: String!) {
|
||||
place(input: { id: \$id }) {
|
||||
id
|
||||
name
|
||||
category
|
||||
businessHours {
|
||||
description
|
||||
isDayOff
|
||||
openTime
|
||||
closeTime
|
||||
dayOfWeek
|
||||
businessHours
|
||||
}
|
||||
phone
|
||||
address {
|
||||
roadAddress
|
||||
jibunAddress
|
||||
}
|
||||
description
|
||||
menuInfo {
|
||||
menus {
|
||||
name
|
||||
price
|
||||
description
|
||||
images {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
keywords
|
||||
priceCategory
|
||||
imageCount
|
||||
visitorReviewCount
|
||||
visitorReviewScore
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
final response = await fetchGraphQL(
|
||||
operationName: 'getKoreanTexts',
|
||||
query: query,
|
||||
variables: {'id': placeId},
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchKoreanTextsFromPcmap error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 장소 기본 정보 가져오기
|
||||
Future<Map<String, dynamic>> fetchPlaceBasicInfo(String placeId) async {
|
||||
const query = '''
|
||||
query getPlaceBasicInfo(\$id: String!) {
|
||||
place(input: { id: \$id }) {
|
||||
id
|
||||
name
|
||||
category
|
||||
phone
|
||||
address {
|
||||
roadAddress
|
||||
jibunAddress
|
||||
}
|
||||
location {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
homepageUrl
|
||||
bookingUrl
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
final response = await fetchGraphQL(
|
||||
operationName: 'getPlaceBasicInfo',
|
||||
query: query,
|
||||
variables: {'id': placeId},
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchPlaceBasicInfo error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
52
lib/data/api/naver/naver_graphql_queries.dart
Normal file
52
lib/data/api/naver/naver_graphql_queries.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 GraphQL \ucffc\ub9ac \ubaa8\uc74c
|
||||
///
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 API\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 GraphQL \ucffc\ub9ac\ub4e4\uc744 \uad00\ub9ac\ud569\ub2c8\ub2e4.
|
||||
class NaverGraphQLQueries {
|
||||
NaverGraphQLQueries._();
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - places \uc0ac\uc6a9
|
||||
static const String placeDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
places(id: \$id) {
|
||||
id
|
||||
name
|
||||
category
|
||||
address
|
||||
roadAddress
|
||||
phone
|
||||
virtualPhone
|
||||
businessHours {
|
||||
description
|
||||
}
|
||||
description
|
||||
location {
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
|
||||
static const String nxPlaceDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
nxPlaces(id: \$id) {
|
||||
id
|
||||
name
|
||||
category
|
||||
address
|
||||
roadAddress
|
||||
phone
|
||||
virtualPhone
|
||||
businessHours {
|
||||
description
|
||||
}
|
||||
description
|
||||
location {
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
197
lib/data/api/naver/naver_local_search_api.dart
Normal file
197
lib/data/api/naver/naver_local_search_api.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/constants/api_keys.dart';
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 로컬 검색 API 결과 모델
|
||||
class NaverLocalSearchResult {
|
||||
final String title;
|
||||
final String link;
|
||||
final String category;
|
||||
final String description;
|
||||
final String telephone;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final double? mapx;
|
||||
final double? mapy;
|
||||
|
||||
NaverLocalSearchResult({
|
||||
required this.title,
|
||||
required this.link,
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.telephone,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
this.mapx,
|
||||
this.mapy,
|
||||
});
|
||||
|
||||
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
// HTML 태그 제거 헬퍼 함수
|
||||
String removeHtmlTags(String text) {
|
||||
return text
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
|
||||
return NaverLocalSearchResult(
|
||||
title: removeHtmlTags(json['title'] ?? ''),
|
||||
link: json['link'] ?? '',
|
||||
category: json['category'] ?? '',
|
||||
description: removeHtmlTags(json['description'] ?? ''),
|
||||
telephone: json['telephone'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
roadAddress: json['roadAddress'] ?? '',
|
||||
mapx: json['mapx'] != null ? double.tryParse(json['mapx'].toString()) : null,
|
||||
mapy: json['mapy'] != null ? double.tryParse(json['mapy'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// link 필드에서 Place ID 추출
|
||||
///
|
||||
/// link가 비어있거나 Place ID가 없으면 null 반환
|
||||
String? extractPlaceId() {
|
||||
if (link.isEmpty) return null;
|
||||
|
||||
// 네이버 지도 URL 패턴에서 Place ID 추출
|
||||
// 예: https://map.naver.com/p/entry/place/1638379069
|
||||
final placeIdMatch = RegExp(r'/place/(\d+)').firstMatch(link);
|
||||
if (placeIdMatch != null) {
|
||||
return placeIdMatch.group(1);
|
||||
}
|
||||
|
||||
// 다른 패턴 시도: restaurant/1638379069
|
||||
final restaurantIdMatch = RegExp(r'/restaurant/(\d+)').firstMatch(link);
|
||||
if (restaurantIdMatch != null) {
|
||||
return restaurantIdMatch.group(1);
|
||||
}
|
||||
|
||||
// ID만 있는 경우 (10자리 숫자)
|
||||
final idOnlyMatch = RegExp(r'(\d{10})').firstMatch(link);
|
||||
if (idOnlyMatch != null) {
|
||||
return idOnlyMatch.group(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 API 클라이언트
|
||||
///
|
||||
/// 네이버 검색 API를 통해 장소 정보를 검색합니다.
|
||||
class NaverLocalSearchApi {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverLocalSearchApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random', // random, comment
|
||||
}) async {
|
||||
// API 키 확인
|
||||
if (!ApiKeys.areKeysConfigured()) {
|
||||
throw ApiKeyException();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
ApiKeys.naverLocalSearchEndpoint,
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'display': display,
|
||||
'start': start,
|
||||
'sort': sort,
|
||||
if (latitude != null && longitude != null) ...{
|
||||
'coordinate': '$longitude,$latitude', // 경도,위도 순서
|
||||
},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Naver-Client-Id': ApiKeys.naverClientId,
|
||||
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data == null || data['items'] == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final items = data['items'] as List;
|
||||
return items
|
||||
.map((item) => NaverLocalSearchResult.fromJson(item))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
throw ServerException(
|
||||
message: '네이버 검색 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 식당 상세 정보 검색
|
||||
Future<NaverLocalSearchResult?> searchRestaurantDetails({
|
||||
required String name,
|
||||
required String address,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
try {
|
||||
// 주소와 이름을 조합한 검색어
|
||||
final query = '$name $address';
|
||||
final results = await searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: 5,
|
||||
sort: 'comment', // 정확도순
|
||||
);
|
||||
|
||||
if (results.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 가장 정확한 결과 찾기
|
||||
for (final result in results) {
|
||||
if (result.title.contains(name) || name.contains(result.title)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
||||
return results.first;
|
||||
} catch (e) {
|
||||
debugPrint('searchRestaurantDetails error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
101
lib/data/api/naver/naver_proxy_client.dart
Normal file
101
lib/data/api/naver/naver_proxy_client.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 프록시 클라이언트
|
||||
///
|
||||
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
|
||||
class NaverProxyClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverProxyClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 웹 환경에서 프록시를 통해 HTML 가져오기
|
||||
Future<String> fetchViaProxy(String url) async {
|
||||
if (!kIsWeb) {
|
||||
throw UnsupportedError('프록시는 웹 환경에서만 사용 가능합니다');
|
||||
}
|
||||
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||
debugPrint('Using proxy URL: $proxyUrl');
|
||||
|
||||
final response = await _networkClient.get<String>(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: '프록시 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('Proxy fetch error: ${e.message}');
|
||||
debugPrint('Status code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${e.response?.data}');
|
||||
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw ServerException(
|
||||
message: 'CORS 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
statusCode: 403,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시를 통한 페이지 로드에 실패했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시 상태 확인
|
||||
Future<bool> checkProxyStatus() async {
|
||||
if (!kIsWeb) {
|
||||
return true; // 웹이 아니면 프록시 불필요
|
||||
}
|
||||
|
||||
try {
|
||||
final testUrl = 'https://map.naver.com';
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
|
||||
|
||||
final response = await _networkClient.head(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Proxy status check failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시 URL 생성
|
||||
String getProxyUrl(String originalUrl) {
|
||||
if (!kIsWeb) {
|
||||
return originalUrl;
|
||||
}
|
||||
return NetworkConfig.getCorsProxyUrl(originalUrl);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
151
lib/data/api/naver/naver_url_resolver.dart
Normal file
151
lib/data/api/naver/naver_url_resolver.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
|
||||
/// 네이버 URL 리졸버
|
||||
///
|
||||
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
|
||||
class NaverUrlResolver {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverUrlResolver({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 단축 URL을 실제 URL로 변환
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
try {
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _resolveShortUrlViaProxy(shortUrl);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 HEAD 요청
|
||||
final response = await _networkClient.head(
|
||||
shortUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status! < 400,
|
||||
),
|
||||
);
|
||||
|
||||
// Location 헤더에서 리다이렉트 URL 추출
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
|
||||
// 리다이렉트가 없으면 원본 URL 반환
|
||||
return shortUrl;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('resolveShortUrl error: $e');
|
||||
|
||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
||||
final location = e.response?.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
// 오류 발생 시 원본 URL 반환
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 단축 URL 해결 (웹 환경)
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
|
||||
|
||||
final response = await _networkClient.get(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => true,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
// 응답에서 URL 정보 추출
|
||||
final responseData = response.data.toString();
|
||||
|
||||
// meta refresh 태그에서 URL 추출
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final metaMatch = metaRefreshRegex.firstMatch(responseData);
|
||||
if (metaMatch != null) {
|
||||
return metaMatch.group(1) ?? shortUrl;
|
||||
}
|
||||
|
||||
// og:url 메타 태그에서 URL 추출
|
||||
final ogUrlRegex = RegExp(
|
||||
'<meta[^>]+property="og:url"[^>]+content="([^"]+)"[^>]*>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final ogMatch = ogUrlRegex.firstMatch(responseData);
|
||||
if (ogMatch != null) {
|
||||
return ogMatch.group(1) ?? shortUrl;
|
||||
}
|
||||
|
||||
// Location 헤더 확인
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('_resolveShortUrlViaProxy error: $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
///
|
||||
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
String currentUrl = url;
|
||||
int redirectCount = 0;
|
||||
const maxRedirects = 5;
|
||||
|
||||
while (redirectCount < maxRedirects) {
|
||||
final response = await _networkClient.head(
|
||||
currentUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status! < 400,
|
||||
),
|
||||
);
|
||||
|
||||
final location = response.headers.value('location');
|
||||
if (location == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 절대 URL로 변환
|
||||
if (location.startsWith('/')) {
|
||||
final uri = Uri.parse(currentUrl);
|
||||
currentUrl = '${uri.scheme}://${uri.host}$location';
|
||||
} else {
|
||||
currentUrl = location;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('getFinalRedirectUrl error: $e');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
217
lib/data/api/naver_api_client.dart
Normal file
217
lib/data/api/naver_api_client.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import 'naver/naver_local_search_api.dart';
|
||||
import 'naver/naver_url_resolver.dart';
|
||||
import 'naver/naver_graphql_api.dart';
|
||||
import 'naver/naver_proxy_client.dart';
|
||||
import 'converters/naver_data_converter.dart';
|
||||
import '../datasources/remote/naver_html_extractor.dart';
|
||||
|
||||
/// 네이버 API 통합 클라이언트
|
||||
///
|
||||
/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다.
|
||||
/// 내부적으로 각 기능별로 분리된 API 클라이언트를 사용합니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
// 분리된 API 클라이언트들
|
||||
late final NaverLocalSearchApi _localSearchApi;
|
||||
late final NaverUrlResolver _urlResolver;
|
||||
late final NaverGraphQLApi _graphqlApi;
|
||||
late final NaverProxyClient _proxyClient;
|
||||
|
||||
NaverApiClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient() {
|
||||
// 각 API 클라이언트 초기화
|
||||
_localSearchApi = NaverLocalSearchApi(networkClient: _networkClient);
|
||||
_urlResolver = NaverUrlResolver(networkClient: _networkClient);
|
||||
_graphqlApi = NaverGraphQLApi(networkClient: _networkClient);
|
||||
_proxyClient = NaverProxyClient(networkClient: _networkClient);
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random',
|
||||
}) async {
|
||||
return _localSearchApi.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: display,
|
||||
start: start,
|
||||
sort: sort,
|
||||
);
|
||||
}
|
||||
|
||||
/// 단축 URL을 실제 URL로 변환
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
return _urlResolver.resolveShortUrl(shortUrl);
|
||||
}
|
||||
|
||||
/// 네이버 지도 페이지 HTML 가져오기
|
||||
Future<String> fetchMapPageHtml(String url) async {
|
||||
try {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _proxyClient.fetchViaProxy(url);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: 'HTML 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchMapPageHtml error: $e');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '페이지를 불러올 수 없습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GraphQL API 호출
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required String query,
|
||||
Map<String, dynamic>? variables,
|
||||
}) async {
|
||||
return _graphqlApi.fetchGraphQL(
|
||||
operationName: operationName,
|
||||
query: query,
|
||||
variables: variables,
|
||||
);
|
||||
}
|
||||
|
||||
/// pcmap URL에서 한글 텍스트 리스트 가져오기
|
||||
///
|
||||
/// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다.
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
// restaurant 타입 URL 사용
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
html = await _proxyClient.fetchViaProxy(pcmapUrl);
|
||||
} else {
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
pcmapUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'HTTP ${response.statusCode}',
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
html = response.data!;
|
||||
}
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
'placeId': placeId,
|
||||
'url': pcmapUrl,
|
||||
'koreanTexts': koreanTexts,
|
||||
'jsonLdName': jsonLdName,
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
return _urlResolver.getFinalRedirectUrl(url);
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_localSearchApi.dispose();
|
||||
_urlResolver.dispose();
|
||||
_graphqlApi.dispose();
|
||||
_proxyClient.dispose();
|
||||
_networkClient.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// NaverLocalSearchResult를 Restaurant으로 변환하는 확장 메서드
|
||||
extension NaverLocalSearchResultExtension on NaverLocalSearchResult {
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
return NaverDataConverter.fromLocalSearchResult(this, id: id);
|
||||
}
|
||||
}
|
||||
553
lib/data/api/naver_api_client.dart.backup
Normal file
553
lib/data/api/naver_api_client.dart.backup
Normal file
@@ -0,0 +1,553 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/constants/api_keys.dart';
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/network/network_config.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../datasources/remote/naver_html_extractor.dart';
|
||||
|
||||
/// 네이버 API 클라이언트
|
||||
///
|
||||
/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverApiClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 네이버 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random', // random, comment
|
||||
}) async {
|
||||
// API 키 확인
|
||||
if (!ApiKeys.areKeysConfigured()) {
|
||||
throw ApiKeyException();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
ApiKeys.naverLocalSearchEndpoint,
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'display': display,
|
||||
'start': start,
|
||||
'sort': sort,
|
||||
if (latitude != null && longitude != null) ...{
|
||||
'coordinate': '$longitude,$latitude', // 경도,위도 순서
|
||||
},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Naver-Client-Id': ApiKeys.naverClientId,
|
||||
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final items = response.data!['items'] as List<dynamic>?;
|
||||
if (items == null || items.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) =>
|
||||
NaverLocalSearchResult.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw ParseException(message: '검색 결과를 파싱할 수 없습니다');
|
||||
} on DioException catch (e) {
|
||||
// 에러는 NetworkClient에서 이미 변환됨
|
||||
throw e.error ??
|
||||
ServerException(message: '네이버 API 호출 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 단축 URL 리다이렉션 처리
|
||||
///
|
||||
/// naver.me 단축 URL을 실제 지도 URL로 변환합니다.
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
if (!shortUrl.contains('naver.me')) {
|
||||
debugPrint('NaverApiClient: 단축 URL이 아님, 원본 반환 - $shortUrl');
|
||||
return shortUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('NaverApiClient: 단축 URL 리디렉션 처리 시작 - $shortUrl');
|
||||
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _resolveShortUrlViaProxy(shortUrl);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 여러 단계의 리다이렉션 처리
|
||||
String currentUrl = shortUrl;
|
||||
int redirectCount = 0;
|
||||
const maxRedirects = 10;
|
||||
|
||||
while (redirectCount < maxRedirects) {
|
||||
debugPrint(
|
||||
'NaverApiClient: 리다이렉션 시도 #${redirectCount + 1} - $currentUrl',
|
||||
);
|
||||
|
||||
final response = await _networkClient.get(
|
||||
currentUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => true, // 모든 상태 코드 허용
|
||||
headers: {'User-Agent': NetworkConfig.userAgent},
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
debugPrint('NaverApiClient: 응답 상태 코드 - ${response.statusCode}');
|
||||
|
||||
// 리다이렉션 체크 (301, 302, 307, 308)
|
||||
if ([301, 302, 307, 308].contains(response.statusCode)) {
|
||||
final location = response.headers['location']?.firstOrNull;
|
||||
if (location != null) {
|
||||
debugPrint('NaverApiClient: Location 헤더 발견 - $location');
|
||||
|
||||
// 상대 경로인 경우 절대 경로로 변환
|
||||
if (!location.startsWith('http')) {
|
||||
final Uri baseUri = Uri.parse(currentUrl);
|
||||
currentUrl = baseUri.resolve(location).toString();
|
||||
} else {
|
||||
currentUrl = location;
|
||||
}
|
||||
|
||||
// 목표 URL에 도달했는지 확인
|
||||
if (currentUrl.contains('pcmap.place.naver.com') ||
|
||||
currentUrl.contains('map.naver.com/p/')) {
|
||||
debugPrint('NaverApiClient: 최종 URL 도착 - $currentUrl');
|
||||
return currentUrl;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
} else {
|
||||
debugPrint('NaverApiClient: Location 헤더 없음');
|
||||
break;
|
||||
}
|
||||
} else if (response.statusCode == 200) {
|
||||
// 200 OK인 경우 meta refresh 태그 확인
|
||||
debugPrint('NaverApiClient: 200 OK - meta refresh 태그 확인');
|
||||
|
||||
final String? html = response.data as String?;
|
||||
if (html != null &&
|
||||
html.contains('meta') &&
|
||||
html.contains('refresh')) {
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv=["\']refresh["\'][^>]+content=["\']\\d+;\\s*url=([^"\'>]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final match = metaRefreshRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final redirectUrl = match.group(1)!;
|
||||
debugPrint('NaverApiClient: Meta refresh URL 발견 - $redirectUrl');
|
||||
|
||||
// 상대 경로 처리
|
||||
if (!redirectUrl.startsWith('http')) {
|
||||
final Uri baseUri = Uri.parse(currentUrl);
|
||||
currentUrl = baseUri.resolve(redirectUrl).toString();
|
||||
} else {
|
||||
currentUrl = redirectUrl;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// meta refresh가 없으면 현재 URL이 최종 URL
|
||||
debugPrint('NaverApiClient: 200 OK - 최종 URL - $currentUrl');
|
||||
return currentUrl;
|
||||
} else {
|
||||
debugPrint('NaverApiClient: 리다이렉션 아님 - 상태 코드 ${response.statusCode}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 시도 후 현재 URL 반환
|
||||
debugPrint('NaverApiClient: 최종 URL - $currentUrl');
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 단축 URL 리다이렉션 실패 - $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 단축 URL 리다이렉션 (웹 환경)
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl =
|
||||
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(shortUrl)}';
|
||||
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
proxyUrl,
|
||||
options: Options(headers: {'Accept': 'application/json'}),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data!;
|
||||
|
||||
// status.url 확인
|
||||
if (data['status'] != null &&
|
||||
data['status'] is Map &&
|
||||
data['status']['url'] != null) {
|
||||
final finalUrl = data['status']['url'] as String;
|
||||
debugPrint('NaverApiClient: 프록시 최종 URL - $finalUrl');
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
// contents에서 meta refresh 태그 찾기
|
||||
final contents = data['contents'] as String?;
|
||||
if (contents != null && contents.isNotEmpty) {
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta\\s+http-equiv=["\']refresh["\']'
|
||||
'\\s+content=["\']0;\\s*url=([^"\']+)["\']',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final match = metaRefreshRegex.firstMatch(contents);
|
||||
if (match != null) {
|
||||
final redirectUrl = match.group(1)!;
|
||||
debugPrint('NaverApiClient: Meta refresh URL - $redirectUrl');
|
||||
return redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 프록시 리다이렉션 실패 - $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 지도 HTML 가져오기
|
||||
///
|
||||
/// 웹 환경에서는 CORS 프록시를 사용합니다.
|
||||
Future<String> fetchMapPageHtml(String url) async {
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
return await _fetchViaProxy(url);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Referer': 'https://map.naver.com',
|
||||
},
|
||||
),
|
||||
useCache: false, // 네이버 지도는 동적 콘텐츠이므로 캐시 사용 안함
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: 'HTML을 가져올 수 없습니다',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw e.error ??
|
||||
ServerException(message: 'HTML 가져오기 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 HTML 가져오기 (웹 환경)
|
||||
Future<String> _fetchViaProxy(String url) async {
|
||||
final proxyUrl =
|
||||
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(url)}';
|
||||
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
proxyUrl,
|
||||
options: Options(headers: {'Accept': 'application/json'}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data!;
|
||||
|
||||
// 상태 코드 확인
|
||||
if (data['status'] != null && data['status'] is Map) {
|
||||
final statusMap = data['status'] as Map<String, dynamic>;
|
||||
final httpCode = statusMap['http_code'];
|
||||
if (httpCode != null && httpCode != 200) {
|
||||
throw ServerException(
|
||||
message: '네이버 서버 응답 오류',
|
||||
statusCode: httpCode as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// contents 반환
|
||||
final contents = data['contents'];
|
||||
if (contents == null || contents.toString().isEmpty) {
|
||||
throw ParseException(message: '빈 응답을 받았습니다');
|
||||
}
|
||||
|
||||
return contents.toString();
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시 요청 실패',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
}
|
||||
|
||||
/// GraphQL 쿼리 실행
|
||||
///
|
||||
/// 네이버 지도 API의 GraphQL 엔드포인트에 요청을 보냅니다.
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required Map<String, dynamic> variables,
|
||||
required String query,
|
||||
}) async {
|
||||
const String graphqlUrl = 'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
try {
|
||||
final response = await _networkClient.post<Map<String, dynamic>>(
|
||||
graphqlUrl,
|
||||
data: {
|
||||
'operationName': operationName,
|
||||
'variables': variables,
|
||||
'query': query,
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
throw ParseException(message: 'GraphQL 응답을 파싱할 수 없습니다');
|
||||
} on DioException catch (e) {
|
||||
throw e.error ??
|
||||
ServerException(message: 'GraphQL 요청 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// pcmap URL에서 한글 텍스트 리스트 가져오기
|
||||
///
|
||||
/// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다.
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
// restaurant 타입 URL 사용
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
html = await _fetchViaProxy(pcmapUrl);
|
||||
} else {
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
pcmapUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
},
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'HTTP ${response.statusCode}',
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
html = response.data!;
|
||||
}
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
'placeId': placeId,
|
||||
'url': pcmapUrl,
|
||||
'koreanTexts': koreanTexts,
|
||||
'jsonLdName': jsonLdName,
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 최종 리디렉션 URL 획득
|
||||
///
|
||||
/// 주어진 URL이 리디렉션되는 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 중 - $url');
|
||||
|
||||
// 429 에러 방지를 위한 지연
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final response = await _networkClient.get(
|
||||
url,
|
||||
options: Options(
|
||||
followRedirects: true,
|
||||
maxRedirects: 5,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
final finalUrl = response.realUri.toString();
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL - $finalUrl');
|
||||
|
||||
return finalUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 실패 - $e');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_networkClient.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 결과
|
||||
class NaverLocalSearchResult {
|
||||
final String title;
|
||||
final String link;
|
||||
final String category;
|
||||
final String description;
|
||||
final String telephone;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final int mapx; // 경도 (x좌표)
|
||||
final int mapy; // 위도 (y좌표)
|
||||
|
||||
NaverLocalSearchResult({
|
||||
required this.title,
|
||||
required this.link,
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.telephone,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
required this.mapx,
|
||||
required this.mapy,
|
||||
});
|
||||
|
||||
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
return NaverLocalSearchResult(
|
||||
title: _removeHtmlTags(json['title'] ?? ''),
|
||||
link: json['link'] ?? '',
|
||||
category: json['category'] ?? '',
|
||||
description: _removeHtmlTags(json['description'] ?? ''),
|
||||
telephone: json['telephone'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
roadAddress: json['roadAddress'] ?? '',
|
||||
mapx: int.tryParse(json['mapx']?.toString() ?? '0') ?? 0,
|
||||
mapy: int.tryParse(json['mapy']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// HTML 태그 제거
|
||||
static String _removeHtmlTags(String text) {
|
||||
return text.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
}
|
||||
|
||||
/// 위도 (십진도)
|
||||
double get latitude => mapy / 10000000.0;
|
||||
|
||||
/// 경도 (십진도)
|
||||
double get longitude => mapx / 10000000.0;
|
||||
|
||||
/// Restaurant 엔티티로 변환
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
// 카테고리 파싱
|
||||
final categories = category.split('>').map((c) => c.trim()).toList();
|
||||
final mainCategory = categories.isNotEmpty ? categories.first : '기타';
|
||||
final subCategory = categories.length > 1 ? categories.last : mainCategory;
|
||||
|
||||
return Restaurant(
|
||||
id: id,
|
||||
name: title,
|
||||
category: mainCategory,
|
||||
subCategory: subCategory,
|
||||
description: description.isNotEmpty ? description : null,
|
||||
phoneNumber: telephone.isNotEmpty ? telephone : null,
|
||||
roadAddress: roadAddress.isNotEmpty ? roadAddress : address,
|
||||
jibunAddress: address,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: null,
|
||||
naverUrl: link.isNotEmpty ? link : null,
|
||||
businessHours: null,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal file
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
||||
class NaverHtmlExtractor {
|
||||
// 제외할 UI 텍스트 패턴 (확장)
|
||||
static const List<String> _excludePatterns = [
|
||||
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
|
||||
'전화번호', '주소', '찾아오시는길', '예약', '홈', '이용약관', '개인정보',
|
||||
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
|
||||
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
|
||||
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
|
||||
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
|
||||
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
|
||||
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
|
||||
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
|
||||
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
|
||||
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
|
||||
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
|
||||
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
|
||||
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
|
||||
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
|
||||
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
|
||||
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
|
||||
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
|
||||
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
|
||||
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
|
||||
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
|
||||
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
|
||||
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
|
||||
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
|
||||
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
|
||||
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
|
||||
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
|
||||
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
|
||||
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
|
||||
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
|
||||
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
|
||||
];
|
||||
|
||||
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
|
||||
static List<String> extractAllValidKoreanTexts(String html) {
|
||||
// script, style 태그 내용 제거
|
||||
var cleanHtml = html.replaceAll(
|
||||
RegExp(r'<script[^>]*>[\s\S]*?</script>', multiLine: true),
|
||||
'',
|
||||
);
|
||||
cleanHtml = cleanHtml.replaceAll(
|
||||
RegExp(r'<style[^>]*>[\s\S]*?</style>', multiLine: true),
|
||||
'',
|
||||
);
|
||||
|
||||
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
|
||||
final contentTags = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'span', 'div', 'li', 'td', 'th',
|
||||
'strong', 'em', 'b', 'i', 'a'
|
||||
];
|
||||
|
||||
final tagPattern = contentTags.map((tag) =>
|
||||
'<$tag[^>]*>([^<]+)</$tag>'
|
||||
).join('|');
|
||||
|
||||
final tagRegex = RegExp(tagPattern, multiLine: true, caseSensitive: false);
|
||||
final tagMatches = tagRegex.allMatches(cleanHtml);
|
||||
|
||||
// 추출된 텍스트 수집
|
||||
final extractedTexts = <String>[];
|
||||
|
||||
for (final match in tagMatches) {
|
||||
final text = match.group(1)?.trim() ?? '';
|
||||
if (text.isNotEmpty && text.contains(RegExp(r'[가-힣]'))) {
|
||||
extractedTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 태그 제거 후 남은 텍스트도 추가
|
||||
final textOnly = cleanHtml.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
final cleanedText = textOnly.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
// 한글 텍스트 추출
|
||||
final koreanPattern = RegExp(r'[가-힣]+(?:\s[가-힣]+)*');
|
||||
final koreanMatches = koreanPattern.allMatches(cleanedText);
|
||||
|
||||
for (final match in koreanMatches) {
|
||||
final text = match.group(0)?.trim() ?? '';
|
||||
if (text.length >= 2) {
|
||||
extractedTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거 및 필터링
|
||||
final uniqueTexts = <String>{};
|
||||
|
||||
for (final text in extractedTexts) {
|
||||
// UI 패턴 제외
|
||||
bool isExcluded = false;
|
||||
for (final pattern in _excludePatterns) {
|
||||
if (text == pattern || text.startsWith(pattern) || text.endsWith(pattern)) {
|
||||
isExcluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExcluded && text.length >= 2 && text.length <= 50) {
|
||||
uniqueTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
/// JSON-LD 데이터에서 장소명 추출
|
||||
static String? extractPlaceNameFromJsonLd(String html) {
|
||||
try {
|
||||
// JSON-LD 스크립트 태그 찾기
|
||||
final jsonLdRegex = RegExp(
|
||||
'<script[^>]*type="application/ld\\+json"[^>]*>([\\s\\S]*?)</script>',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
final matches = jsonLdRegex.allMatches(html);
|
||||
for (final match in matches) {
|
||||
final jsonString = match.group(1);
|
||||
if (jsonString == null) continue;
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> json = jsonDecode(jsonString);
|
||||
|
||||
// Restaurant 타입 확인
|
||||
if (json['@type'] == 'Restaurant' ||
|
||||
json['@type'] == 'LocalBusiness') {
|
||||
final name = json['name'] as String?;
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// @graph 배열 확인
|
||||
if (json['@graph'] is List) {
|
||||
final graph = json['@graph'] as List;
|
||||
for (final item in graph) {
|
||||
if (item is Map<String, dynamic> &&
|
||||
(item['@type'] == 'Restaurant' ||
|
||||
item['@type'] == 'LocalBusiness')) {
|
||||
final name = item['name'] as String?;
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패, 다음 매치로 이동
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Apollo State에서 장소명 추출
|
||||
static String? extractPlaceNameFromApolloState(String html) {
|
||||
try {
|
||||
// window.__APOLLO_STATE__ 패턴 찾기
|
||||
final apolloRegex = RegExp(
|
||||
'window\\.__APOLLO_STATE__\\s*=\\s*\\{([\\s\\S]*?)\\};',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
final match = apolloRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final apolloJson = match.group(1);
|
||||
if (apolloJson != null) {
|
||||
try {
|
||||
final Map<String, dynamic> apolloState = jsonDecode(
|
||||
'{$apolloJson}',
|
||||
);
|
||||
|
||||
// Place 객체들 찾기
|
||||
for (final entry in apolloState.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
// 'name' 필드가 있는 Place 객체 찾기
|
||||
if (value['__typename'] == 'Place' ||
|
||||
value['__typename'] == 'Restaurant') {
|
||||
final name = value['name'] as String?;
|
||||
if (name != null &&
|
||||
name.isNotEmpty &&
|
||||
!name.contains('네이버')) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패
|
||||
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// HTML에서 Place URL 추출 (og:url 메타 태그)
|
||||
static String? extractPlaceLink(String html) {
|
||||
try {
|
||||
// og:url 메타 태그에서 추출
|
||||
final ogUrlRegex = RegExp(
|
||||
r'<meta[^>]+property="og:url"[^>]+content="([^"]+)"',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final match = ogUrlRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final url = match.group(1);
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
// canonical 링크 태그에서 추출
|
||||
final canonicalRegex = RegExp(
|
||||
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final canonicalMatch = canonicalRegex.firstMatch(html);
|
||||
if (canonicalMatch != null) {
|
||||
final url = canonicalMatch.group(1);
|
||||
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
305
lib/data/datasources/remote/naver_html_parser.dart
Normal file
305
lib/data/datasources/remote/naver_html_parser.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
|
||||
class NaverHtmlParser {
|
||||
// CSS 셀렉터 상수
|
||||
static const List<String> _nameSelectors = [
|
||||
'span.GHAhO',
|
||||
'h1.Qpe7b',
|
||||
'span.Fc1rA',
|
||||
'[class*="place_name"]',
|
||||
'meta[property="og:title"]',
|
||||
];
|
||||
|
||||
static const List<String> _categorySelectors = [
|
||||
'span.DJJvD',
|
||||
'span.lnJFt',
|
||||
'[class*="category"]',
|
||||
];
|
||||
|
||||
static const List<String> _descriptionSelectors = [
|
||||
'span.IH7VW',
|
||||
'div.vV_z_',
|
||||
'meta[name="description"]',
|
||||
];
|
||||
|
||||
static const List<String> _phoneSelectors = [
|
||||
'span.xlx7Q',
|
||||
'a[href^="tel:"]',
|
||||
'[class*="phone"]',
|
||||
];
|
||||
|
||||
static const List<String> _addressSelectors = [
|
||||
'span.IH7VW',
|
||||
'span.jWDO_',
|
||||
'[class*="address"]',
|
||||
];
|
||||
|
||||
static const List<String> _businessHoursSelectors = [
|
||||
'time.aT6WB',
|
||||
'div.O8qbU',
|
||||
'[class*="business"]',
|
||||
'[class*="hours"]',
|
||||
];
|
||||
|
||||
/// HTML 문서에서 식당 정보 추출
|
||||
Map<String, dynamic> parseRestaurantInfo(Document document) {
|
||||
return {
|
||||
'name': extractName(document),
|
||||
'category': extractCategory(document),
|
||||
'subCategory': extractSubCategory(document),
|
||||
'description': extractDescription(document),
|
||||
'phone': extractPhoneNumber(document),
|
||||
'roadAddress': extractRoadAddress(document),
|
||||
'address': extractJibunAddress(document),
|
||||
'latitude': extractLatitude(document),
|
||||
'longitude': extractLongitude(document),
|
||||
'businessHours': extractBusinessHours(document),
|
||||
};
|
||||
}
|
||||
|
||||
/// 식당 이름 추출
|
||||
String? extractName(Document document) {
|
||||
try {
|
||||
for (final selector in _nameSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'meta') {
|
||||
return element.attributes['content'];
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 카테고리 추출
|
||||
String? extractCategory(Document document) {
|
||||
try {
|
||||
for (final selector in _categorySelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
// 첫 번째 카테고리만 추출 (예: "한식 > 국밥" -> "한식")
|
||||
return text.split('>').first.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 서브 카테고리 추출
|
||||
String? extractSubCategory(Document document) {
|
||||
try {
|
||||
final element = document.querySelector('span.DJJvD, span.lnJFt');
|
||||
if (element != null) {
|
||||
final text = element.text.trim();
|
||||
if (text.contains('>')) {
|
||||
// 두 번째 카테고리 반환 (예: "한식 > 국밥" -> "국밥")
|
||||
return text.split('>').last.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 설명 추출
|
||||
String? extractDescription(Document document) {
|
||||
try {
|
||||
for (final selector in _descriptionSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'meta') {
|
||||
return element.attributes['content'];
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 전화번호 추출
|
||||
String? extractPhoneNumber(Document document) {
|
||||
try {
|
||||
for (final selector in _phoneSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'a' && element.attributes['href'] != null) {
|
||||
return element.attributes['href']!.replaceFirst('tel:', '');
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty && RegExp(r'[\d\-\+\(\)]+').hasMatch(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 도로명 주소 추출
|
||||
String? extractRoadAddress(Document document) {
|
||||
try {
|
||||
for (final selector in _addressSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 도로명 주소 패턴 확인
|
||||
if (text.contains('로') || text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 지번 주소 추출
|
||||
String? extractJibunAddress(Document document) {
|
||||
try {
|
||||
for (final selector in _addressSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
!text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 위도 추출
|
||||
double? extractLatitude(Document document) {
|
||||
try {
|
||||
// 메타 태그에서 좌표 정보 찾기
|
||||
final metaElement = document.querySelector('meta[property="og:url"]');
|
||||
if (metaElement != null) {
|
||||
final content = metaElement.attributes['content'];
|
||||
if (content != null) {
|
||||
// URL에서 좌표 파라미터 추출 (예: ?y=37.5666805)
|
||||
final RegExp latRegex = RegExp(r'[?&]y=(\d+\.\d+)');
|
||||
final match = latRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
final content = script.text;
|
||||
if (content.contains('latitude') || content.contains('lat')) {
|
||||
final RegExp latRegex = RegExp(r'(?:latitude|lat)["\s:]+(\d+\.\d+)');
|
||||
final match = latRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 경도 추출
|
||||
double? extractLongitude(Document document) {
|
||||
try {
|
||||
// 메타 태그에서 좌표 정보 찾기
|
||||
final metaElement = document.querySelector('meta[property="og:url"]');
|
||||
if (metaElement != null) {
|
||||
final content = metaElement.attributes['content'];
|
||||
if (content != null) {
|
||||
// URL에서 좌표 파라미터 추출 (예: ?x=126.9784147)
|
||||
final RegExp lonRegex = RegExp(r'[?&]x=(\d+\.\d+)');
|
||||
final match = lonRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
final content = script.text;
|
||||
if (content.contains('longitude') || content.contains('lng')) {
|
||||
final RegExp lonRegex = RegExp(r'(?:longitude|lng)["\s:]+(\d+\.\d+)');
|
||||
final match = lonRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 영업시간 추출
|
||||
String? extractBusinessHours(Document document) {
|
||||
try {
|
||||
for (final selector in _businessHoursSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
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';
|
||||
}
|
||||
251
lib/data/datasources/remote/naver_search_service.dart
Normal file
251
lib/data/datasources/remote/naver_search_service.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
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 '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);
|
||||
|
||||
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,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 유사도 계산 (간단한 버전)
|
||||
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,
|
||||
) {
|
||||
return _findBestMatch(targetName, results);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
double calculateSimilarityForTesting(String str1, String str2) {
|
||||
return _calculateSimilarity(str1, str2);
|
||||
}
|
||||
}
|
||||
117
lib/data/repositories/recommendation_repository_impl.dart
Normal file
117
lib/data/repositories/recommendation_repository_impl.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
|
||||
class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
static const String _boxName = 'recommendations';
|
||||
|
||||
Future<Box<RecommendationRecord>> get _box async =>
|
||||
await Hive.openBox<RecommendationRecord>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getAllRecommendationRecords() async {
|
||||
final box = await _box;
|
||||
final records = box.values.toList();
|
||||
records.sort((a, b) => b.recommendationDate.compareTo(a.recommendationDate));
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.year == date.year &&
|
||||
record.recommendationDate.month == date.month &&
|
||||
record.recommendationDate.day == date.day;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
record.recommendationDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addRecommendationRecord(RecommendationRecord record) async {
|
||||
final box = await _box;
|
||||
await box.put(record.id, record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecommendationRecord(RecommendationRecord record) async {
|
||||
final box = await _box;
|
||||
await box.put(record.id, record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRecommendationRecord(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markAsVisited(String recommendationId) async {
|
||||
final box = await _box;
|
||||
final record = box.get(recommendationId);
|
||||
if (record != null) {
|
||||
final updatedRecord = RecommendationRecord(
|
||||
id: record.id,
|
||||
restaurantId: record.restaurantId,
|
||||
recommendationDate: record.recommendationDate,
|
||||
visited: true,
|
||||
createdAt: record.createdAt,
|
||||
);
|
||||
await updateRecommendationRecord(updatedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getTodayRecommendationCount() async {
|
||||
final today = DateTime.now();
|
||||
final todayRecords = await getRecommendationsByDate(today);
|
||||
return todayRecords.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<RecommendationRecord>> watchRecommendationRecords() async* {
|
||||
final box = await _box;
|
||||
try {
|
||||
yield await getAllRecommendationRecords();
|
||||
} catch (_) {
|
||||
yield <RecommendationRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllRecommendationRecords());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
final records = await getRecommendationsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (final record in records) {
|
||||
final dayKey = record.recommendationDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
254
lib/data/repositories/restaurant_repository_impl.dart
Normal file
254
lib/data/repositories/restaurant_repository_impl.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
final NaverSearchService _naverSearchService;
|
||||
|
||||
RestaurantRepositoryImpl({
|
||||
NaverSearchService? naverSearchService,
|
||||
}) : _naverSearchService = naverSearchService ?? NaverSearchService();
|
||||
|
||||
Future<Box<Restaurant>> get _box async =>
|
||||
await Hive.openBox<Restaurant>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getAllRestaurants() async {
|
||||
final box = await _box;
|
||||
return box.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant?> getRestaurantById(String id) async {
|
||||
final box = await _box;
|
||||
return box.get(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addRestaurant(Restaurant restaurant) async {
|
||||
final box = await _box;
|
||||
await box.put(restaurant.id, restaurant);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRestaurant(Restaurant restaurant) async {
|
||||
final box = await _box;
|
||||
await box.put(restaurant.id, restaurant);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRestaurant(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsByCategory(String category) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
return restaurants.where((r) => r.category == category).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getAllCategories() async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
final categories = restaurants.map((r) => r.category).toSet().toList();
|
||||
categories.sort();
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<Restaurant>> watchRestaurants() async* {
|
||||
final box = await _box;
|
||||
yield box.values.toList();
|
||||
yield* box.watch().map((_) => box.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
|
||||
final restaurant = await getRestaurantById(restaurantId);
|
||||
if (restaurant != null) {
|
||||
final updatedRestaurant = Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
lastVisitDate: visitDate,
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: restaurant.businessHours,
|
||||
lastVisited: visitDate,
|
||||
visitCount: restaurant.visitCount + 1,
|
||||
);
|
||||
await updateRestaurant(updatedRestaurant);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsWithinDistance({
|
||||
required double userLatitude,
|
||||
required double userLongitude,
|
||||
required double maxDistanceInMeters,
|
||||
}) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
return restaurants.where((restaurant) {
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: userLatitude,
|
||||
lon1: userLongitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters <= maxDistanceInMeters;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsNotVisitedInDays(int days) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
final cutoffDate = DateTime.now().subtract(Duration(days: days));
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
if (restaurant.lastVisitDate == null) return true;
|
||||
return restaurant.lastVisitDate!.isBefore(cutoffDate);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> searchRestaurants(String query) async {
|
||||
if (query.isEmpty) {
|
||||
return await getAllRestaurants();
|
||||
}
|
||||
|
||||
final restaurants = await getAllRestaurants();
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
return restaurant.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ?? false) ||
|
||||
restaurant.category.toLowerCase().contains(lowercaseQuery) ||
|
||||
restaurant.roadAddress.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
||||
try {
|
||||
// URL 유효성 검증
|
||||
if (!url.contains('naver.com') && !url.contains('naver.me')) {
|
||||
throw Exception('유효하지 않은 네이버 지도 URL입니다.');
|
||||
}
|
||||
|
||||
// NaverSearchService로 식당 정보 추출
|
||||
Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(url);
|
||||
|
||||
// API 키가 설정되어 있으면 추가 정보 검색
|
||||
if (ApiKeys.areKeysConfigured() && restaurant.name != '네이버 지도 장소') {
|
||||
try {
|
||||
final detailedRestaurant = await _naverSearchService.searchRestaurantDetails(
|
||||
name: restaurant.name,
|
||||
address: restaurant.roadAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
);
|
||||
|
||||
if (detailedRestaurant != null) {
|
||||
// 기존 정보와 API 검색 결과 병합
|
||||
restaurant = Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: detailedRestaurant.category,
|
||||
subCategory: detailedRestaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
roadAddress: detailedRestaurant.roadAddress,
|
||||
jibunAddress: detailedRestaurant.jibunAddress,
|
||||
latitude: detailedRestaurant.latitude,
|
||||
longitude: detailedRestaurant.longitude,
|
||||
lastVisitDate: restaurant.lastVisitDate,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 - Place ID가 있는 경우
|
||||
if (restaurant.naverPlaceId != null) {
|
||||
final existingRestaurant = await getRestaurantByNaverPlaceId(restaurant.naverPlaceId!);
|
||||
if (existingRestaurant != null) {
|
||||
throw Exception('이미 등록된 맛집입니다: ${existingRestaurant.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 - 이름과 주소로 추가 확인
|
||||
final restaurants = await getAllRestaurants();
|
||||
final duplicate = restaurants.firstWhere(
|
||||
(r) => r.name == restaurant.name &&
|
||||
(r.roadAddress == restaurant.roadAddress ||
|
||||
r.jibunAddress == restaurant.jibunAddress),
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
subCategory: '',
|
||||
roadAddress: '',
|
||||
jibunAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (duplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${duplicate.name}');
|
||||
}
|
||||
|
||||
// 새 맛집 추가
|
||||
await addRestaurant(restaurant);
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
try {
|
||||
return restaurants.firstWhere(
|
||||
(r) => r.naverPlaceId == naverPlaceId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
204
lib/data/repositories/settings_repository_impl.dart
Normal file
204
lib/data/repositories/settings_repository_impl.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
|
||||
class SettingsRepositoryImpl implements SettingsRepository {
|
||||
static const String _boxName = 'settings';
|
||||
|
||||
// Setting keys
|
||||
static const String _keyDaysToExclude = 'days_to_exclude';
|
||||
static const String _keyMaxDistanceRainy = 'max_distance_rainy';
|
||||
static const String _keyMaxDistanceNormal = 'max_distance_normal';
|
||||
static const String _keyNotificationDelayMinutes = 'notification_delay_minutes';
|
||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||
static const String _keyFirstRun = 'first_run';
|
||||
static const String _keyCategoryWeights = 'category_weights';
|
||||
|
||||
// Default values
|
||||
static const int _defaultDaysToExclude = 7;
|
||||
static const int _defaultMaxDistanceRainy = 500;
|
||||
static const int _defaultMaxDistanceNormal = 1000;
|
||||
static const int _defaultNotificationDelayMinutes = 90;
|
||||
static const bool _defaultNotificationEnabled = true;
|
||||
static const bool _defaultDarkModeEnabled = false;
|
||||
static const bool _defaultFirstRun = true;
|
||||
|
||||
Future<Box> get _box async => await Hive.openBox(_boxName);
|
||||
|
||||
@override
|
||||
Future<UserSettings> getUserSettings() async {
|
||||
final box = await _box;
|
||||
|
||||
// 저장된 설정값들을 읽어옴
|
||||
final revisitPreventionDays = box.get(_keyDaysToExclude, defaultValue: _defaultDaysToExclude);
|
||||
final notificationEnabled = box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
final notificationDelayMinutes = box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
|
||||
// 카테고리 가중치 읽기 (Map<String, double>으로 저장됨)
|
||||
final categoryWeightsData = box.get(_keyCategoryWeights);
|
||||
Map<String, double> categoryWeights = {};
|
||||
if (categoryWeightsData != null) {
|
||||
categoryWeights = Map<String, double>.from(categoryWeightsData);
|
||||
}
|
||||
|
||||
// 알림 시간은 분을 시간:분 형식으로 변환
|
||||
final hours = notificationDelayMinutes ~/ 60;
|
||||
final minutes = notificationDelayMinutes % 60;
|
||||
final notificationTime = '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}';
|
||||
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled,
|
||||
notificationTime: notificationTime,
|
||||
categoryWeights: categoryWeights,
|
||||
notificationDelayMinutes: notificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateUserSettings(UserSettings settings) async {
|
||||
final box = await _box;
|
||||
|
||||
// 각 설정값 저장
|
||||
await box.put(_keyDaysToExclude, settings.revisitPreventionDays);
|
||||
await box.put(_keyNotificationEnabled, settings.notificationEnabled);
|
||||
await box.put(_keyNotificationDelayMinutes, settings.notificationDelayMinutes);
|
||||
|
||||
// 카테고리 가중치 저장
|
||||
await box.put(_keyCategoryWeights, settings.categoryWeights);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getDaysToExclude() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyDaysToExclude, defaultValue: _defaultDaysToExclude);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDaysToExclude(int days) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyDaysToExclude, days);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getMaxDistanceRainy() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceRainy, defaultValue: _defaultMaxDistanceRainy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMaxDistanceRainy(int meters) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyMaxDistanceRainy, meters);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getMaxDistanceNormal() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceNormal, defaultValue: _defaultMaxDistanceNormal);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMaxDistanceNormal(int meters) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyMaxDistanceNormal, meters);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getNotificationDelayMinutes() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNotificationDelayMinutes(int minutes) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyNotificationDelayMinutes, minutes);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isNotificationEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNotificationEnabled(bool enabled) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyNotificationEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isDarkModeEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyDarkModeEnabled, defaultValue: _defaultDarkModeEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDarkModeEnabled(bool enabled) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyDarkModeEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isFirstRun() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyFirstRun, defaultValue: _defaultFirstRun);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFirstRun(bool isFirst) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyFirstRun, isFirst);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resetSettings() async {
|
||||
final box = await _box;
|
||||
await box.clear();
|
||||
|
||||
// 기본값으로 재설정
|
||||
await box.put(_keyDaysToExclude, _defaultDaysToExclude);
|
||||
await box.put(_keyMaxDistanceRainy, _defaultMaxDistanceRainy);
|
||||
await box.put(_keyMaxDistanceNormal, _defaultMaxDistanceNormal);
|
||||
await box.put(_keyNotificationDelayMinutes, _defaultNotificationDelayMinutes);
|
||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> watchSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
// 초기 값 전송
|
||||
yield await _getCurrentSettings();
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await _getCurrentSettings());
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getCurrentSettings() async {
|
||||
return {
|
||||
_keyDaysToExclude: await getDaysToExclude(),
|
||||
_keyMaxDistanceRainy: await getMaxDistanceRainy(),
|
||||
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
||||
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
||||
_keyNotificationEnabled: await isNotificationEnabled(),
|
||||
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
||||
_keyFirstRun: await isFirstRun(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UserSettings> watchUserSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
// 초기 값 전송
|
||||
yield await getUserSettings();
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await getUserSettings());
|
||||
}
|
||||
}
|
||||
127
lib/data/repositories/visit_repository_impl.dart
Normal file
127
lib/data/repositories/visit_repository_impl.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
|
||||
class VisitRepositoryImpl implements VisitRepository {
|
||||
static const String _boxName = 'visit_records';
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
await Hive.openBox<VisitRecord>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getAllVisitRecords() async {
|
||||
final box = await _box;
|
||||
final records = box.values.toList();
|
||||
records.sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByDate(DateTime date) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.year == date.year &&
|
||||
record.visitDate.month == date.month &&
|
||||
record.visitDate.day == date.day;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
record.visitDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVisitRecord(VisitRecord visitRecord) async {
|
||||
final box = await _box;
|
||||
await box.put(visitRecord.id, visitRecord);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVisitRecord(VisitRecord visitRecord) async {
|
||||
final box = await _box;
|
||||
await box.put(visitRecord.id, visitRecord);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteVisitRecord(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmVisit(String visitRecordId) async {
|
||||
final box = await _box;
|
||||
final record = box.get(visitRecordId);
|
||||
if (record != null) {
|
||||
final updatedRecord = VisitRecord(
|
||||
id: record.id,
|
||||
restaurantId: record.restaurantId,
|
||||
visitDate: record.visitDate,
|
||||
isConfirmed: true,
|
||||
createdAt: record.createdAt,
|
||||
);
|
||||
await updateVisitRecord(updatedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<VisitRecord>> watchVisitRecords() async* {
|
||||
final box = await _box;
|
||||
try {
|
||||
yield await getAllVisitRecords();
|
||||
} catch (_) {
|
||||
yield <VisitRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllVisitRecords());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getLastVisitDate(String restaurantId) async {
|
||||
final records = await getVisitRecordsByRestaurantId(restaurantId);
|
||||
if (records.isEmpty) return null;
|
||||
|
||||
// 이미 visitDate 기준으로 정렬되어 있으므로 첫 번째가 가장 최근
|
||||
return records.first.visitDate;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyVisitStats(int year, int month) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
final records = await getVisitRecordsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (final record in records) {
|
||||
final dayKey = record.visitDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getCategoryVisitStats() async {
|
||||
// 이 메서드는 RestaurantRepository와 연동이 필요하므로
|
||||
// 실제 구현은 UseCase나 Provider 레벨에서 처리
|
||||
// 여기서는 빈 Map 반환
|
||||
return {};
|
||||
}
|
||||
}
|
||||
194
lib/data/repositories/weather_repository_impl.dart
Normal file
194
lib/data/repositories/weather_repository_impl.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
|
||||
class WeatherRepositoryImpl implements WeatherRepository {
|
||||
static const String _boxName = 'weather_cache';
|
||||
static const String _keyCachedWeather = 'cached_weather';
|
||||
static const String _keyLastUpdateTime = 'last_update_time';
|
||||
static const Duration _cacheValidDuration = Duration(hours: 1);
|
||||
|
||||
Future<Box> get _box async => await Hive.openBox(_boxName);
|
||||
|
||||
@override
|
||||
Future<WeatherInfo> getCurrentWeather({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 22,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
return dummyWeather;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WeatherInfo?> getCachedWeather() async {
|
||||
final box = await _box;
|
||||
|
||||
// 캐시가 유효한지 확인
|
||||
final isValid = await _isCacheValid();
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 캐시된 데이터 가져오기
|
||||
final cachedData = box.get(_keyCachedWeather);
|
||||
if (cachedData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print('WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(cachedData);
|
||||
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') || !weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
print('WeatherCache: Error parsing cached weather data: $e');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo) async {
|
||||
final box = await _box;
|
||||
|
||||
// WeatherInfo를 Map으로 변환하여 저장
|
||||
final weatherMap = _weatherInfoToMap(weatherInfo);
|
||||
await box.put(_keyCachedWeather, weatherMap);
|
||||
await box.put(_keyLastUpdateTime, DateTime.now().toIso8601String());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearWeatherCache() async {
|
||||
final box = await _box;
|
||||
await box.delete(_keyCachedWeather);
|
||||
await box.delete(_keyLastUpdateTime);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isWeatherUpdateNeeded() async {
|
||||
final box = await _box;
|
||||
|
||||
// 캐시된 날씨 정보가 없으면 업데이트 필요
|
||||
if (!box.containsKey(_keyCachedWeather)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 캐시가 유효한지 확인
|
||||
return !(await _isCacheValid());
|
||||
}
|
||||
|
||||
Future<bool> _isCacheValid() async {
|
||||
final box = await _box;
|
||||
|
||||
final lastUpdateTimeStr = box.get(_keyLastUpdateTime);
|
||||
if (lastUpdateTimeStr == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 날짜 파싱 시도
|
||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||
if (lastUpdateTime == null) {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
return false;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastUpdateTime);
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _weatherInfoToMap(WeatherInfo weatherInfo) {
|
||||
return {
|
||||
'current': {
|
||||
'temperature': weatherInfo.current.temperature,
|
||||
'isRainy': weatherInfo.current.isRainy,
|
||||
'description': weatherInfo.current.description,
|
||||
},
|
||||
'nextHour': {
|
||||
'temperature': weatherInfo.nextHour.temperature,
|
||||
'isRainy': weatherInfo.nextHour.isRainy,
|
||||
'description': weatherInfo.nextHour.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
||||
try {
|
||||
// current 필드 검증
|
||||
final currentMap = map['current'] as Map<String, dynamic>?;
|
||||
if (currentMap == null) {
|
||||
throw FormatException('Missing current weather data');
|
||||
}
|
||||
|
||||
// nextHour 필드 검증
|
||||
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
||||
if (nextHourMap == null) {
|
||||
throw FormatException('Missing nextHour weather data');
|
||||
}
|
||||
|
||||
// 필수 필드 검증 및 기본값 제공
|
||||
final currentTemp = currentMap['temperature'] as num? ?? 20;
|
||||
final currentRainy = currentMap['isRainy'] as bool? ?? false;
|
||||
final currentDesc = currentMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
final nextTemp = nextHourMap['temperature'] as num? ?? 20;
|
||||
final nextRainy = nextHourMap['isRainy'] as bool? ?? false;
|
||||
final nextDesc = nextHourMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
return WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: currentTemp.round(),
|
||||
isRainy: currentRainy,
|
||||
description: currentDesc,
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: nextTemp.round(),
|
||||
isRainy: nextRainy,
|
||||
description: nextDesc,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
||||
print('WeatherCache: Map data: $map');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user