feat(app): add manual entry and sharing flows
This commit is contained in:
@@ -5,7 +5,7 @@ import '../naver/naver_local_search_api.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 데이터 변환기
|
||||
///
|
||||
///
|
||||
/// 네이버 API 응답을 도메인 엔티티로 변환합니다.
|
||||
class NaverDataConverter {
|
||||
static const _uuid = Uuid();
|
||||
@@ -22,13 +22,21 @@ class NaverDataConverter {
|
||||
);
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final categoryParts = result.category.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = result.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: result.title,
|
||||
@@ -36,8 +44,8 @@ class NaverDataConverter {
|
||||
subCategory: subCategory,
|
||||
description: result.description.isNotEmpty ? result.description : null,
|
||||
phoneNumber: result.telephone.isNotEmpty ? result.telephone : null,
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
: result.address,
|
||||
jibunAddress: result.address,
|
||||
latitude: convertedCoords['latitude'] ?? 37.5665,
|
||||
@@ -77,10 +85,15 @@ class NaverDataConverter {
|
||||
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;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
@@ -116,11 +129,6 @@ class NaverDataConverter {
|
||||
final longitude = mapx / 10000000.0;
|
||||
final latitude = mapy / 10000000.0;
|
||||
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
return {'latitude': latitude, 'longitude': longitude};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ 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';
|
||||
|
||||
static const String _graphqlEndpoint =
|
||||
'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
NaverGraphQLApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
@@ -40,9 +41,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -106,9 +105,7 @@ class NaverGraphQLApi {
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -149,9 +146,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -164,4 +159,4 @@ class NaverGraphQLApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// \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!) {
|
||||
@@ -26,7 +26,7 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
|
||||
static const String nxPlaceDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
@@ -49,4 +49,4 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,42 +50,46 @@ class NaverLocalSearchResult {
|
||||
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,
|
||||
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;
|
||||
@@ -142,7 +146,7 @@ class NaverLocalSearchApi {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
@@ -194,4 +198,4 @@ class NaverLocalSearchApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../../core/network/network_config.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 프록시 클라이언트
|
||||
///
|
||||
///
|
||||
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
|
||||
class NaverProxyClient {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -23,22 +23,21 @@ class NaverProxyClient {
|
||||
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':
|
||||
'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: '프록시 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: '프록시 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -46,7 +45,7 @@ class NaverProxyClient {
|
||||
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 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
@@ -54,7 +53,7 @@ class NaverProxyClient {
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시를 통한 페이지 로드에 실패했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -72,12 +71,10 @@ class NaverProxyClient {
|
||||
try {
|
||||
final testUrl = 'https://map.naver.com';
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.head(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
options: Options(validateStatus: (status) => status! < 500),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
@@ -98,4 +95,4 @@ class NaverProxyClient {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
|
||||
/// 네이버 URL 리졸버
|
||||
///
|
||||
///
|
||||
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
|
||||
class NaverUrlResolver {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -40,7 +40,7 @@ class NaverUrlResolver {
|
||||
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');
|
||||
@@ -58,7 +58,7 @@ class NaverUrlResolver {
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.get(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
@@ -70,7 +70,7 @@ class NaverUrlResolver {
|
||||
|
||||
// 응답에서 URL 정보 추출
|
||||
final responseData = response.data.toString();
|
||||
|
||||
|
||||
// meta refresh 태그에서 URL 추출
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
|
||||
@@ -105,7 +105,7 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
///
|
||||
///
|
||||
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
@@ -148,4 +148,4 @@ class NaverUrlResolver {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import '../datasources/remote/naver_html_extractor.dart';
|
||||
/// 내부적으로 각 기능별로 분리된 API 클라이언트를 사용합니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
|
||||
// 분리된 API 클라이언트들
|
||||
late final NaverLocalSearchApi _localSearchApi;
|
||||
late final NaverUrlResolver _urlResolver;
|
||||
@@ -73,27 +73,27 @@ class NaverApiClient {
|
||||
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',
|
||||
'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 응답이 비어있습니다',
|
||||
);
|
||||
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,
|
||||
@@ -138,7 +138,8 @@ class NaverApiClient {
|
||||
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',
|
||||
'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/',
|
||||
@@ -162,12 +163,14 @@ class NaverApiClient {
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(
|
||||
html,
|
||||
);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
@@ -214,4 +217,4 @@ extension NaverLocalSearchResultExtension on NaverLocalSearchResult {
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
return NaverDataConverter.fromLocalSearchResult(this, id: id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user