feat(app): finalize ad gated flows and weather
- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
@@ -46,7 +46,11 @@ class NaverGraphQLApi {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchGraphQL error: $e');
|
||||
AppLogger.error(
|
||||
'fetchGraphQL error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
throw ServerException(
|
||||
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -104,13 +108,17 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
AppLogger.error('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchKoreanTextsFromPcmap error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'fetchKoreanTextsFromPcmap error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -150,8 +158,12 @@ class NaverGraphQLApi {
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchPlaceBasicInfo error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'fetchPlaceBasicInfo error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/constants/api_keys.dart';
|
||||
import '../../../core/network/network_client.dart';
|
||||
@@ -143,9 +143,13 @@ class NaverLocalSearchApi {
|
||||
.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}');
|
||||
AppLogger.error(
|
||||
'NaverLocalSearchApi error: ${e.message}',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
AppLogger.debug('Error type: ${e.type}');
|
||||
AppLogger.debug('Error response: ${e.response?.data}');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
@@ -189,8 +193,12 @@ class NaverLocalSearchApi {
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
||||
return results.first;
|
||||
} catch (e) {
|
||||
debugPrint('searchRestaurantDetails error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'searchRestaurantDetails error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
@@ -22,7 +23,7 @@ class NaverProxyClient {
|
||||
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||
debugPrint('Using proxy URL: $proxyUrl');
|
||||
AppLogger.debug('Using proxy URL: $proxyUrl');
|
||||
|
||||
final response = await _networkClient.get<String>(
|
||||
proxyUrl,
|
||||
@@ -42,9 +43,13 @@ class NaverProxyClient {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('Proxy fetch error: ${e.message}');
|
||||
debugPrint('Status code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${e.response?.data}');
|
||||
AppLogger.error(
|
||||
'Proxy fetch error: ${e.message}',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
AppLogger.debug('Status code: ${e.response?.statusCode}');
|
||||
AppLogger.debug('Response: ${e.response?.data}');
|
||||
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw ServerException(
|
||||
@@ -78,8 +83,12 @@ class NaverProxyClient {
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Proxy status check failed: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Proxy status check failed: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
@@ -39,7 +40,11 @@ class NaverUrlResolver {
|
||||
// 리다이렉트가 없으면 원본 URL 반환
|
||||
return shortUrl;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('resolveShortUrl error: $e');
|
||||
AppLogger.error(
|
||||
'resolveShortUrl error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
|
||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
||||
@@ -98,8 +103,12 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('_resolveShortUrlViaProxy error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'_resolveShortUrlViaProxy error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
@@ -139,8 +148,12 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('getFinalRedirectUrl error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'getFinalRedirectUrl error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
@@ -88,7 +89,11 @@ class NaverApiClient {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchMapPageHtml error: $e');
|
||||
AppLogger.error(
|
||||
'fetchMapPageHtml error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
@@ -123,9 +128,9 @@ class NaverApiClient {
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
AppLogger.debug('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
AppLogger.debug('요청 URL: $pcmapUrl');
|
||||
AppLogger.debug('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
@@ -148,7 +153,7 @@ class NaverApiClient {
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
AppLogger.error(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
@@ -172,11 +177,11 @@ class NaverApiClient {
|
||||
html,
|
||||
);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
AppLogger.debug('========== 추출 결과 ==========');
|
||||
AppLogger.debug('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
AppLogger.debug('JSON-LD 상호명: $jsonLdName');
|
||||
AppLogger.debug('Apollo State 상호명: $apolloName');
|
||||
AppLogger.debug('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
@@ -187,8 +192,12 @@ class NaverApiClient {
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverApiClient: pcmap 페이지 파싱 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
||||
class NaverHtmlExtractor {
|
||||
@@ -323,11 +323,11 @@ class NaverHtmlExtractor {
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
AppLogger.debug('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
AppLogger.debug('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
return resultList;
|
||||
}
|
||||
@@ -377,8 +377,12 @@ class NaverHtmlExtractor {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: JSON-LD 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -418,14 +422,21 @@ class NaverHtmlExtractor {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패
|
||||
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Apollo State 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -442,7 +453,7 @@ class NaverHtmlExtractor {
|
||||
final match = ogUrlRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final url = match.group(1);
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
AppLogger.debug('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -454,11 +465,15 @@ class NaverHtmlExtractor {
|
||||
final canonicalMatch = canonicalRegex.firstMatch(html);
|
||||
if (canonicalMatch != null) {
|
||||
final url = canonicalMatch.group(1);
|
||||
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Place Link 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
@@ -77,8 +77,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 이름 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -97,8 +101,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 카테고리 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -115,8 +123,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 서브 카테고리 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -137,8 +149,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 설명 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -159,8 +175,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 전화번호 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -179,8 +199,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 도로명 주소 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -201,8 +225,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 지번 주소 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -238,8 +266,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 위도 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -275,8 +307,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 경도 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -297,8 +333,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 영업시간 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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 'package:html/parser.dart' as html_parser;
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../api/naver/naver_graphql_queries.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
import 'naver_html_parser.dart';
|
||||
|
||||
/// 네이버 지도 URL 파서
|
||||
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
|
||||
@@ -60,9 +62,7 @@ class NaverMapParser {
|
||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||
}
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Starting to parse URL: $url');
|
||||
|
||||
// URL 유효성 검증
|
||||
if (!_isValidNaverUrl(url)) {
|
||||
@@ -72,9 +72,7 @@ class NaverMapParser {
|
||||
// 짧은 URL인 경우 리다이렉트 처리
|
||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
|
||||
// Place ID 추출 (10자리 숫자)
|
||||
final String? placeId = _extractPlaceId(finalUrl);
|
||||
@@ -82,11 +80,9 @@ class NaverMapParser {
|
||||
// 짧은 URL에서 직접 ID 추출 시도
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
||||
);
|
||||
return _createFallbackRestaurant(shortUrlId, url);
|
||||
}
|
||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||
@@ -96,9 +92,7 @@ class NaverMapParser {
|
||||
final isShortUrl = url.contains('naver.me');
|
||||
|
||||
if (isShortUrl) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
|
||||
try {
|
||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||
@@ -108,14 +102,14 @@ class NaverMapParser {
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
@@ -177,9 +171,7 @@ class NaverMapParser {
|
||||
}) async {
|
||||
// 심플한 접근: URL로 직접 검색
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: URL 기반 검색 시작');
|
||||
|
||||
// 네이버 지도 URL 구성
|
||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
@@ -201,27 +193,25 @@ class NaverMapParser {
|
||||
// place ID가 포함된 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
if (result.link.contains(placeId)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: URL 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
@@ -238,17 +228,17 @@ class NaverMapParser {
|
||||
);
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: Place ID 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
@@ -258,10 +248,12 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: URL 기반 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
@@ -275,9 +267,7 @@ class NaverMapParser {
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying places query...');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Trying places query...');
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
@@ -293,17 +283,17 @@ class NaverMapParser {
|
||||
return _extractPlaceData(placesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: places query failed - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying nxPlaces query...');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Trying nxPlaces query...');
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
@@ -319,18 +309,18 @@ class NaverMapParser {
|
||||
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: nxPlaces query failed - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
return await _fallbackToHtmlParsing(placeId);
|
||||
}
|
||||
|
||||
@@ -508,7 +498,7 @@ class NaverMapParser {
|
||||
double? userLongitude,
|
||||
) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
AppLogger.debug('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
// 1. 한글 텍스트 추출
|
||||
@@ -525,17 +515,17 @@ class NaverMapParser {
|
||||
if (koreanData['jsonLdName'] != null) {
|
||||
searchQuery = koreanData['jsonLdName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanData['apolloStateName'] != null) {
|
||||
searchQuery = koreanData['apolloStateName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanTexts.isNotEmpty) {
|
||||
searchQuery = koreanTexts.first as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||
}
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
@@ -543,7 +533,7 @@ class NaverMapParser {
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
AppLogger.debug('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(
|
||||
@@ -563,15 +553,15 @@ class NaverMapParser {
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
AppLogger.debug('=== 로컬 검색 결과 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)');
|
||||
AppLogger.debug('[$i] ${result.title}');
|
||||
AppLogger.debug(' 링크: ${result.link}');
|
||||
AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
AppLogger.debug('=====================================');
|
||||
}
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
@@ -583,7 +573,7 @@ class NaverMapParser {
|
||||
if (extractedId == placeId) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||
AppLogger.debug('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -604,7 +594,7 @@ class NaverMapParser {
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||
AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -620,7 +610,7 @@ class NaverMapParser {
|
||||
userLongitude,
|
||||
);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +618,7 @@ class NaverMapParser {
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,7 +660,7 @@ class NaverMapParser {
|
||||
}
|
||||
|
||||
if (kDebugMode && nearest != null) {
|
||||
debugPrint(
|
||||
AppLogger.debug(
|
||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.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 '../../../domain/entities/restaurant.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
import 'naver_url_processor.dart';
|
||||
|
||||
/// 네이버 검색 서비스
|
||||
///
|
||||
@@ -12,14 +15,21 @@ import 'naver_map_parser.dart';
|
||||
class NaverSearchService {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final NaverUrlProcessor _urlProcessor;
|
||||
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);
|
||||
NaverSearchService({
|
||||
NaverApiClient? apiClient,
|
||||
NaverMapParser? mapParser,
|
||||
NaverUrlProcessor? urlProcessor,
|
||||
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient),
|
||||
_urlProcessor =
|
||||
urlProcessor ??
|
||||
NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
@@ -32,7 +42,7 @@ class NaverSearchService {
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
||||
try {
|
||||
return await _mapParser.parseRestaurantFromUrl(url);
|
||||
return await _urlProcessor.processUrl(url);
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
@@ -149,9 +159,9 @@ class NaverSearchService {
|
||||
);
|
||||
} catch (e) {
|
||||
// 상세 파싱 실패해도 기본 정보 반환
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
|
||||
}
|
||||
AppLogger.debug(
|
||||
'[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
lib/data/datasources/remote/naver_url_processor.dart
Normal file
42
lib/data/datasources/remote/naver_url_processor.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
import '../../api/naver_api_client.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
|
||||
/// 네이버 지도 URL을 처리하고 결과를 캐시하는 경량 프로세서.
|
||||
/// - 단축 URL 해석 → 지도 파서 실행
|
||||
/// - 동일 URL 재요청 시 메모리 캐시 반환
|
||||
class NaverUrlProcessor {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final _cache = HashMap<String, Restaurant>();
|
||||
|
||||
NaverUrlProcessor({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
Future<Restaurant> processUrl(
|
||||
String url, {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
final normalizedUrl = url.trim();
|
||||
if (_cache.containsKey(normalizedUrl)) {
|
||||
return _cache[normalizedUrl]!;
|
||||
}
|
||||
|
||||
final resolved = await _apiClient.resolveShortUrl(normalizedUrl);
|
||||
final restaurant = await _mapParser.parseRestaurantFromUrl(
|
||||
resolved,
|
||||
userLatitude: userLatitude,
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
_cache[normalizedUrl] = restaurant;
|
||||
_cache[resolved] = restaurant;
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
void clearCache() => _cache.clear();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
@@ -224,7 +225,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
|
||||
@@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
final cached = await getCachedWeather();
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'),
|
||||
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
return dummyWeather;
|
||||
try {
|
||||
final weather = await _fetchWeatherFromKma(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
await cacheWeatherInfo(weather);
|
||||
return weather;
|
||||
} catch (_) {
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
return WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '날씨 정보를 불러오지 못했어요',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '날씨 정보를 불러오지 못했어요',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
@@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') ||
|
||||
!weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Missing required fields in weather data',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
print('WeatherCache: Error parsing cached weather data: $e');
|
||||
AppLogger.error(
|
||||
'WeatherCache: Error parsing cached weather data',
|
||||
error: e,
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// 날짜 파싱 시도
|
||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||
if (lastUpdateTime == null) {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
AppLogger.error('WeatherCache: Error checking cache validity', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -183,9 +210,284 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
||||
print('WeatherCache: Map data: $map');
|
||||
AppLogger.error(
|
||||
'WeatherCache: Error converting map to WeatherInfo',
|
||||
error: e,
|
||||
stackTrace: StackTrace.current,
|
||||
);
|
||||
AppLogger.debug('WeatherCache: Map data: $map');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<WeatherInfo> _fetchWeatherFromKma({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
final serviceKey = _encodeServiceKey(ApiKeys.weatherServiceKey);
|
||||
if (serviceKey.isEmpty) {
|
||||
throw Exception('기상청 서비스 키가 설정되지 않았습니다.');
|
||||
}
|
||||
|
||||
final gridPoint = _latLonToGrid(latitude, longitude);
|
||||
final baseDateTime = _resolveBaseDateTime();
|
||||
|
||||
final ncstUri = Uri.https(
|
||||
'apis.data.go.kr',
|
||||
'/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst',
|
||||
{
|
||||
'serviceKey': serviceKey,
|
||||
'numOfRows': '100',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': baseDateTime.date,
|
||||
'base_time': baseDateTime.ncstTime,
|
||||
'nx': gridPoint.x.toString(),
|
||||
'ny': gridPoint.y.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final fcstUri = Uri.https(
|
||||
'apis.data.go.kr',
|
||||
'/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst',
|
||||
{
|
||||
'serviceKey': serviceKey,
|
||||
'numOfRows': '200',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': baseDateTime.date,
|
||||
'base_time': baseDateTime.fcstTime,
|
||||
'nx': gridPoint.x.toString(),
|
||||
'ny': gridPoint.y.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final ncstItems = await _requestKmaItems(ncstUri);
|
||||
final fcstItems = await _requestKmaItems(fcstUri);
|
||||
|
||||
final currentTemp = _extractLatestValue(ncstItems, 'T1H')?.round();
|
||||
final currentPty = _extractLatestValue(ncstItems, 'PTY')?.round() ?? 0;
|
||||
final currentSky = _extractLatestValue(ncstItems, 'SKY')?.round() ?? 1;
|
||||
|
||||
final now = DateTime.now();
|
||||
final nextHourData = _extractForecast(fcstItems, after: now);
|
||||
final nextTemp = nextHourData.temperature?.round();
|
||||
final nextPty = nextHourData.pty ?? 0;
|
||||
final nextSky = nextHourData.sky ?? 1;
|
||||
|
||||
final currentWeather = WeatherData(
|
||||
temperature: currentTemp ?? 20,
|
||||
isRainy: _isRainy(currentPty),
|
||||
description: _describeWeather(currentSky, currentPty),
|
||||
);
|
||||
|
||||
final nextWeather = WeatherData(
|
||||
temperature: nextTemp ?? currentTemp ?? 20,
|
||||
isRainy: _isRainy(nextPty),
|
||||
description: _describeWeather(nextSky, nextPty),
|
||||
);
|
||||
|
||||
return WeatherInfo(current: currentWeather, nextHour: nextWeather);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _requestKmaItems(Uri uri) async {
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Weather API 호출 실패: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = body['response']?['body']?['items']?['item'];
|
||||
if (items is List<dynamic>) {
|
||||
return items;
|
||||
}
|
||||
throw Exception('Weather API 응답 파싱 실패');
|
||||
}
|
||||
|
||||
double? _extractLatestValue(List<dynamic> items, String category) {
|
||||
final filtered = items.where((item) => item['category'] == category);
|
||||
if (filtered.isEmpty) return null;
|
||||
final sorted = filtered.toList()
|
||||
..sort((a, b) {
|
||||
final dateA = a['baseDate'] as String? ?? '';
|
||||
final timeA = a['baseTime'] as String? ?? '';
|
||||
final dateB = b['baseDate'] as String? ?? '';
|
||||
final timeB = b['baseTime'] as String? ?? '';
|
||||
final dtA = _parseKmaDateTime(dateA, timeA);
|
||||
final dtB = _parseKmaDateTime(dateB, timeB);
|
||||
return dtB.compareTo(dtA);
|
||||
});
|
||||
final value = sorted.first['obsrValue'] ?? sorted.first['fcstValue'];
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
({double? temperature, int? pty, int? sky}) _extractForecast(
|
||||
List<dynamic> items, {
|
||||
required DateTime after,
|
||||
}) {
|
||||
DateTime? targetTime;
|
||||
double? temperature;
|
||||
int? pty;
|
||||
int? sky;
|
||||
|
||||
DateTime fcstDateTime(Map<String, dynamic> item) {
|
||||
final date = item['fcstDate'] as String? ?? '';
|
||||
final time = item['fcstTime'] as String? ?? '';
|
||||
return _parseKmaDateTime(date, time);
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
final dt = fcstDateTime(item as Map<String, dynamic>);
|
||||
if (!dt.isAfter(after)) continue;
|
||||
if (targetTime == null || dt.isBefore(targetTime)) {
|
||||
targetTime = dt;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTime == null) {
|
||||
return (temperature: null, pty: null, sky: null);
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final dt = fcstDateTime(map);
|
||||
if (dt != targetTime) continue;
|
||||
|
||||
final category = map['category'];
|
||||
final value = map['fcstValue'];
|
||||
if (value == null) continue;
|
||||
|
||||
if (category == 'T1H' && temperature == null) {
|
||||
temperature = value is num
|
||||
? value.toDouble()
|
||||
: double.tryParse('$value');
|
||||
} else if (category == 'PTY' && pty == null) {
|
||||
pty = value is num ? value.toInt() : int.tryParse('$value');
|
||||
} else if (category == 'SKY' && sky == null) {
|
||||
sky = value is num ? value.toInt() : int.tryParse('$value');
|
||||
}
|
||||
}
|
||||
|
||||
return (temperature: temperature, pty: pty, sky: sky);
|
||||
}
|
||||
|
||||
_GridPoint _latLonToGrid(double lat, double lon) {
|
||||
const re = 6371.00877;
|
||||
const grid = 5.0;
|
||||
const slat1 = 30.0 * pi / 180.0;
|
||||
const slat2 = 60.0 * pi / 180.0;
|
||||
const olon = 126.0 * pi / 180.0;
|
||||
const olat = 38.0 * pi / 180.0;
|
||||
const xo = 43.0;
|
||||
const yo = 136.0;
|
||||
|
||||
final sn =
|
||||
log(cos(slat1) / cos(slat2)) /
|
||||
log(tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5));
|
||||
final sf = pow(tan(pi * 0.25 + slat1 * 0.5), sn) * cos(slat1) / sn;
|
||||
final ro = re / grid * sf / pow(tan(pi * 0.25 + olat * 0.5), sn);
|
||||
final ra =
|
||||
re / grid * sf / pow(tan(pi * 0.25 + (lat * pi / 180.0) * 0.5), sn);
|
||||
var theta = lon * pi / 180.0 - olon;
|
||||
if (theta > pi) theta -= 2.0 * pi;
|
||||
if (theta < -pi) theta += 2.0 * pi;
|
||||
theta *= sn;
|
||||
|
||||
final x = (ra * sin(theta) + xo + 0.5).floor();
|
||||
final y = (ro - ra * cos(theta) + yo + 0.5).floor();
|
||||
return _GridPoint(x: x, y: y);
|
||||
}
|
||||
|
||||
bool _isRainy(int pty) => pty > 0;
|
||||
|
||||
String _describeWeather(int sky, int pty) {
|
||||
if (pty == 1) return '비';
|
||||
if (pty == 2) return '비/눈';
|
||||
if (pty == 3) return '눈';
|
||||
if (pty == 4) return '소나기';
|
||||
if (pty == 5) return '빗방울';
|
||||
if (pty == 6) return '빗방울/눈날림';
|
||||
if (pty == 7) return '눈날림';
|
||||
|
||||
switch (sky) {
|
||||
case 1:
|
||||
return '맑음';
|
||||
case 3:
|
||||
return '구름 많음';
|
||||
case 4:
|
||||
return '흐림';
|
||||
default:
|
||||
return '맑음';
|
||||
}
|
||||
}
|
||||
|
||||
/// 서비스 키를 안전하게 URL 인코딩한다.
|
||||
/// 이미 인코딩된 값(%)이 포함되어 있으면 그대로 사용한다.
|
||||
String _encodeServiceKey(String key) {
|
||||
if (key.isEmpty) return '';
|
||||
if (key.contains('%')) return key;
|
||||
return Uri.encodeComponent(key);
|
||||
}
|
||||
|
||||
_BaseDateTime _resolveBaseDateTime() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 초단기실황은 매시 정시 발표(정시+10분 이후 호출 권장)
|
||||
// 초단기예보는 매시 30분 발표(30분+10분 이후 호출 권장)
|
||||
final ncstAnchor = now.minute >= 10
|
||||
? DateTime(now.year, now.month, now.day, now.hour, 0)
|
||||
: DateTime(now.year, now.month, now.day, now.hour - 1, 0);
|
||||
final fcstAnchor = now.minute >= 40
|
||||
? DateTime(now.year, now.month, now.day, now.hour, 30)
|
||||
: DateTime(now.year, now.month, now.day, now.hour - 1, 30);
|
||||
|
||||
final date = _formatDate(fcstAnchor); // 둘 다 같은 날짜/시점 기준
|
||||
final ncstTime = _formatTime(ncstAnchor);
|
||||
final fcstTime = _formatTime(fcstAnchor);
|
||||
|
||||
return _BaseDateTime(date: date, ncstTime: ncstTime, fcstTime: fcstTime);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
final y = dt.year.toString().padLeft(4, '0');
|
||||
final m = dt.month.toString().padLeft(2, '0');
|
||||
final d = dt.day.toString().padLeft(2, '0');
|
||||
return '$y$m$d';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
return '$h$m';
|
||||
}
|
||||
|
||||
DateTime _parseKmaDateTime(String date, String time) {
|
||||
final year = int.parse(date.substring(0, 4));
|
||||
final month = int.parse(date.substring(4, 6));
|
||||
final day = int.parse(date.substring(6, 8));
|
||||
final hour = int.parse(time.substring(0, 2));
|
||||
final minute = int.parse(time.substring(2, 4));
|
||||
return DateTime(year, month, day, hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPoint {
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
_GridPoint({required this.x, required this.y});
|
||||
}
|
||||
|
||||
class _BaseDateTime {
|
||||
final String date;
|
||||
final String ncstTime;
|
||||
final String fcstTime;
|
||||
|
||||
_BaseDateTime({
|
||||
required this.date,
|
||||
required this.ncstTime,
|
||||
required this.fcstTime,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user