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> 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>( 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?; if (items == null || items.isEmpty) { return []; } return items .map( (item) => NaverLocalSearchResult.fromJson(item as Map), ) .toList(); } throw ParseException(message: '검색 결과를 파싱할 수 없습니다'); } on DioException catch (e) { // 에러는 NetworkClient에서 이미 변환됨 throw e.error ?? ServerException(message: '네이버 API 호출 실패', statusCode: 500); } } /// 네이버 단축 URL 리다이렉션 처리 /// /// naver.me 단축 URL을 실제 지도 URL로 변환합니다. Future 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( ']+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 _resolveShortUrlViaProxy(String shortUrl) async { try { final proxyUrl = '${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(shortUrl)}'; final response = await _networkClient.get>( 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( ' fetchMapPageHtml(String url) async { try { if (kIsWeb) { return await _fetchViaProxy(url); } // 모바일 환경에서는 직접 요청 final response = await _networkClient.get( 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 _fetchViaProxy(String url) async { final proxyUrl = '${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(url)}'; final response = await _networkClient.get>( 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; 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> fetchGraphQL({ required String operationName, required Map variables, required String query, }) async { const String graphqlUrl = 'https://pcmap-api.place.naver.com/graphql'; try { final response = await _networkClient.post>( 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> 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( 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': [], }; } 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': [], }; } } /// 최종 리디렉션 URL 획득 /// /// 주어진 URL이 리디렉션되는 최종 URL을 반환합니다. Future 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 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, ); } }