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:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View 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,
};
}
}

View 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() {
// 필요시 리소스 정리
}
}

View 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
}
}
}
''';
}

View 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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
}
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() {
// 필요시 리소스 정리
}
}

View 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() {
// 필요시 리소스 정리
}
}

View 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() {
// 필요시 리소스 정리
}
}

View 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);
}
}

View 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,
);
}
}

View 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;
}
}

View 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;
}
}
}

View 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';
}

View 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);
}
}

View 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;
}
}

View 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;
}
}
}

View 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());
}
}

View 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 {};
}
}

View 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;
}
}
}