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

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

View File

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

View File

@@ -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 {
}
}
''';
}
}

View File

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

View File

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

View File

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

View File

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