feat(app): add manual entry and sharing flows

This commit is contained in:
JiWoong Sul
2025-11-19 16:36:39 +09:00
parent 5ade584370
commit 947fe59486
110 changed files with 5937 additions and 3781 deletions

View File

@@ -18,34 +18,37 @@ import '../../../core/utils/category_mapper.dart';
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 _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})
bool _isDisposed = false;
NaverMapParser({NaverApiClient? apiClient})
: _apiClient = apiClient ?? NaverApiClient();
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
///
///
/// 지원하는 URL 형식:
/// - https://map.naver.com/p/restaurant/1234567890
/// - https://naver.me/abcdefgh
///
///
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
/// 가장 가까운 위치의 식당을 선택합니다.
Future<Restaurant> parseRestaurantFromUrl(
@@ -53,23 +56,26 @@ class NaverMapParser {
double? userLatitude,
double? userLongitude,
}) async {
if (_isDisposed) {
throw NaverMapParseException('이미 dispose된 파서입니다');
}
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) {
@@ -77,24 +83,31 @@ class NaverMapParser {
final shortUrlId = _extractShortUrlId(url);
if (shortUrlId != null) {
if (kDebugMode) {
debugPrint('NaverMapParser: Using short URL ID as place ID: $shortUrlId');
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);
final restaurant = await _parseWithLocalSearch(
placeId,
finalUrl,
userLatitude,
userLongitude,
);
if (kDebugMode) {
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
}
@@ -106,7 +119,7 @@ class NaverMapParser {
// 실패 시 기본 파싱으로 계속 진행
}
}
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
final restaurantData = await _fetchRestaurantFromGraphQL(
placeId,
@@ -114,7 +127,6 @@ class NaverMapParser {
userLongitude: userLongitude,
);
return _createRestaurant(restaurantData, placeId, finalUrl);
} catch (e) {
if (e is NaverMapParseException) {
rethrow;
@@ -128,7 +140,7 @@ class NaverMapParser {
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
}
}
/// URL이 유효한 네이버 지도 URL인지 확인
bool _isValidNaverUrl(String url) {
try {
@@ -138,15 +150,15 @@ class NaverMapParser {
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 {
@@ -156,7 +168,7 @@ class NaverMapParser {
return null;
}
}
/// GraphQL API로 식당 정보 가져오기
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
String placeId, {
@@ -168,35 +180,41 @@ class NaverMapParser {
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 방지
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}');
debugPrint(
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
);
}
return _convertSearchResultToData(result);
}
}
// 정확한 매칭이 없으면 첫 번째 결과 사용
if (kDebugMode) {
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
debugPrint(
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
);
}
return _convertSearchResultToData(searchResults.first);
}
@@ -205,21 +223,25 @@ class NaverMapParser {
debugPrint('NaverMapParser: URL 검색 실패 - $e');
}
}
// Step 2: Place ID로 검색
try {
await Future.delayed(const Duration(milliseconds: _longDelayMillis)); // 더 긴 지연
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}');
debugPrint(
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
);
}
return _convertSearchResultToData(searchResults.first);
}
@@ -227,7 +249,7 @@ class NaverMapParser {
if (kDebugMode) {
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
}
// 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) {
throw RateLimitException(
@@ -236,12 +258,11 @@ class NaverMapParser {
);
}
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
}
// 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) {
throw RateLimitException(
@@ -250,7 +271,7 @@ class NaverMapParser {
);
}
}
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
// 첫 번째 시도: places 쿼리
try {
@@ -262,7 +283,7 @@ class NaverMapParser {
variables: {'id': placeId},
query: NaverGraphQLQueries.placeDetailQuery,
);
// places 응답 처리 (배열일 수도 있음)
final placesData = response['data']?['places'];
if (placesData != null) {
@@ -277,7 +298,7 @@ class NaverMapParser {
debugPrint('NaverMapParser: places query failed - $e');
}
}
// 두 번째 시도: nxPlaces 쿼리
try {
if (kDebugMode) {
@@ -288,7 +309,7 @@ class NaverMapParser {
variables: {'id': placeId},
query: NaverGraphQLQueries.nxPlaceDetailQuery,
);
// nxPlaces 응답 처리 (배열일 수도 있음)
final nxPlacesData = response['data']?['nxPlaces'];
if (nxPlacesData != null) {
@@ -303,21 +324,28 @@ class NaverMapParser {
debugPrint('NaverMapParser: nxPlaces query failed - $e');
}
}
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
if (kDebugMode) {
debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing');
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 categoryParts = item.category
.split('>')
.map((s) => s.trim())
.toList();
final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
final subCategory = categoryParts.length > 1
? categoryParts.last
: category;
return {
'name': item.title,
'category': category,
@@ -326,25 +354,32 @@ class NaverMapParser {
'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에서는 영업시간 정보 제공 안 함
'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();
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,
@@ -360,26 +395,24 @@ class NaverMapParser {
: 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,
);
throw RateLimitException(originalError: e);
}
rethrow;
}
}
/// Restaurant 객체 생성
Restaurant _createRestaurant(
Map<String, dynamic> data,
@@ -397,25 +430,33 @@ class NaverMapParser {
final double? latitude = data['latitude'];
final double? longitude = data['longitude'];
final String? businessHours = data['businessHours'];
// 카테고리 정규화
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(rawCategory, rawSubCategory);
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 : '주소 정보를 가져올 수 없습니다';
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 ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
description:
description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
phoneNumber: phoneNumber,
roadAddress: finalRoadAddress,
jibunAddress: finalJibunAddress,
@@ -432,7 +473,7 @@ class NaverMapParser {
visitCount: 0,
);
}
/// 기본 정보로 Restaurant 생성 (Fallback)
Restaurant _createFallbackRestaurant(String placeId, String url) {
return Restaurant(
@@ -457,7 +498,7 @@ class NaverMapParser {
visitCount: 0,
);
}
/// 단축 URL을 위한 향상된 파싱 메서드
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
Future<Restaurant> _parseWithLocalSearch(
@@ -469,16 +510,16 @@ class NaverMapParser {
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) {
@@ -499,25 +540,27 @@ class NaverMapParser {
} else {
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
}
// 2. 로컬 검색 API 호출
if (kDebugMode) {
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
}
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
await Future.delayed(
const Duration(milliseconds: _shortDelayMillis),
); // 429 에러 방지
final searchResults = await _apiClient.searchLocal(
query: searchQuery,
latitude: userLatitude,
longitude: userLongitude,
display: 20, // 더 많은 결과 검색
display: 20, // 더 많은 결과 검색
);
if (searchResults.isEmpty) {
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
}
// 디버깅: 검색 결과 Place ID 분석
if (kDebugMode) {
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
@@ -530,10 +573,10 @@ class NaverMapParser {
}
debugPrint('=====================================');
}
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
NaverLocalSearchResult? bestMatch;
// 1차: Place ID가 정확히 일치하는 결과 찾기
for (final result in searchResults) {
final extractedId = result.extractPlaceId();
@@ -545,18 +588,19 @@ class NaverMapParser {
break;
}
}
// 2차: 상호명이 유사한 결과 찾기
if (bestMatch == null) {
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
String? exactName = koreanData['jsonLdName'] as String? ??
koreanData['apolloStateName'] as String?;
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) ||
if (result.title == exactName ||
result.title.contains(exactName) ||
exactName.contains(result.title)) {
bestMatch = result;
if (kDebugMode) {
@@ -567,15 +611,19 @@ class NaverMapParser {
}
}
}
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
if (bestMatch == null && userLatitude != null && userLongitude != null) {
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
bestMatch = _findNearestResult(
searchResults,
userLatitude,
userLongitude,
);
if (bestMatch != null && kDebugMode) {
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
}
}
// 최종: 첫 번째 결과 사용
if (bestMatch == null) {
bestMatch = searchResults.first;
@@ -583,10 +631,10 @@ class NaverMapParser {
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
}
}
// 4. Restaurant 객체 생성
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
// 추가 정보 보완
return restaurant.copyWith(
naverPlaceId: placeId,
@@ -595,7 +643,7 @@ class NaverMapParser {
updatedAt: DateTime.now(),
);
}
/// 가장 가까운 결과 찾기 (거리 기반)
NaverLocalSearchResult? _findNearestResult(
List<NaverLocalSearchResult> results,
@@ -604,56 +652,66 @@ class NaverMapParser {
) {
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)');
debugPrint(
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
);
}
return nearest;
}
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
///
///
/// 반환값: 킬로미터 단위의 거리
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
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)) +
final double a =
(sin(dLat / 2) * sin(dLat / 2)) +
(cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2) * sin(dLon / 2));
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
/// 리소스 정리
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
_apiClient.dispose();
}
}
@@ -661,9 +719,9 @@ class NaverMapParser {
/// 네이버 지도 파싱 예외
class NaverMapParseException implements Exception {
final String message;
NaverMapParseException(this.message);
@override
String toString() => 'NaverMapParseException: $message';
}
}