feat: 초기 프로젝트 설정 및 LunchPick 앱 구현

LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다.

주요 기능:
- 네이버 지도 연동 맛집 추가
- 랜덤 메뉴 추천 시스템
- 날씨 기반 거리 조정
- 방문 기록 관리
- Bluetooth 맛집 공유
- 다크모드 지원

기술 스택:
- Flutter 3.8.1+
- Riverpod 상태 관리
- Hive 로컬 DB
- Clean Architecture

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
class NaverHtmlExtractor {
// 제외할 UI 텍스트 패턴 (확장)
static const List<String> _excludePatterns = [
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
'전화번호', '주소', '찾아오시는길', '예약', '', '이용약관', '개인정보',
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
];
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
static List<String> extractAllValidKoreanTexts(String html) {
// script, style 태그 내용 제거
var cleanHtml = html.replaceAll(
RegExp(r'<script[^>]*>[\s\S]*?</script>', multiLine: true),
'',
);
cleanHtml = cleanHtml.replaceAll(
RegExp(r'<style[^>]*>[\s\S]*?</style>', multiLine: true),
'',
);
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
final contentTags = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'span', 'div', 'li', 'td', 'th',
'strong', 'em', 'b', 'i', 'a'
];
final tagPattern = contentTags.map((tag) =>
'<$tag[^>]*>([^<]+)</$tag>'
).join('|');
final tagRegex = RegExp(tagPattern, multiLine: true, caseSensitive: false);
final tagMatches = tagRegex.allMatches(cleanHtml);
// 추출된 텍스트 수집
final extractedTexts = <String>[];
for (final match in tagMatches) {
final text = match.group(1)?.trim() ?? '';
if (text.isNotEmpty && text.contains(RegExp(r'[가-힣]'))) {
extractedTexts.add(text);
}
}
// 모든 태그 제거 후 남은 텍스트도 추가
final textOnly = cleanHtml.replaceAll(RegExp(r'<[^>]+>'), ' ');
final cleanedText = textOnly.replaceAll(RegExp(r'\s+'), ' ').trim();
// 한글 텍스트 추출
final koreanPattern = RegExp(r'[가-힣]+(?:\s[가-힣]+)*');
final koreanMatches = koreanPattern.allMatches(cleanedText);
for (final match in koreanMatches) {
final text = match.group(0)?.trim() ?? '';
if (text.length >= 2) {
extractedTexts.add(text);
}
}
// 중복 제거 및 필터링
final uniqueTexts = <String>{};
for (final text in extractedTexts) {
// UI 패턴 제외
bool isExcluded = false;
for (final pattern in _excludePatterns) {
if (text == pattern || text.startsWith(pattern) || text.endsWith(pattern)) {
isExcluded = true;
break;
}
}
if (!isExcluded && text.length >= 2 && text.length <= 50) {
uniqueTexts.add(text);
}
}
// 리스트로 변환하여 반환
final resultList = uniqueTexts.toList();
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
for (int i = 0; i < resultList.length; i++) {
debugPrint('[$i] ${resultList[i]}');
}
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
return resultList;
}
/// JSON-LD 데이터에서 장소명 추출
static String? extractPlaceNameFromJsonLd(String html) {
try {
// JSON-LD 스크립트 태그 찾기
final jsonLdRegex = RegExp(
'<script[^>]*type="application/ld\\+json"[^>]*>([\\s\\S]*?)</script>',
multiLine: true,
);
final matches = jsonLdRegex.allMatches(html);
for (final match in matches) {
final jsonString = match.group(1);
if (jsonString == null) continue;
try {
final Map<String, dynamic> json = jsonDecode(jsonString);
// Restaurant 타입 확인
if (json['@type'] == 'Restaurant' ||
json['@type'] == 'LocalBusiness') {
final name = json['name'] as String?;
if (name != null && name.isNotEmpty) {
return name;
}
}
// @graph 배열 확인
if (json['@graph'] is List) {
final graph = json['@graph'] as List;
for (final item in graph) {
if (item is Map<String, dynamic> &&
(item['@type'] == 'Restaurant' ||
item['@type'] == 'LocalBusiness')) {
final name = item['name'] as String?;
if (name != null && name.isNotEmpty) {
return name;
}
}
}
}
} catch (e) {
// JSON 파싱 실패, 다음 매치로 이동
continue;
}
}
} catch (e) {
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
}
return null;
}
/// Apollo State에서 장소명 추출
static String? extractPlaceNameFromApolloState(String html) {
try {
// window.__APOLLO_STATE__ 패턴 찾기
final apolloRegex = RegExp(
'window\\.__APOLLO_STATE__\\s*=\\s*\\{([\\s\\S]*?)\\};',
multiLine: true,
);
final match = apolloRegex.firstMatch(html);
if (match != null) {
final apolloJson = match.group(1);
if (apolloJson != null) {
try {
final Map<String, dynamic> apolloState = jsonDecode(
'{$apolloJson}',
);
// Place 객체들 찾기
for (final entry in apolloState.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
// 'name' 필드가 있는 Place 객체 찾기
if (value['__typename'] == 'Place' ||
value['__typename'] == 'Restaurant') {
final name = value['name'] as String?;
if (name != null &&
name.isNotEmpty &&
!name.contains('네이버')) {
return name;
}
}
}
}
} catch (e) {
// JSON 파싱 실패
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
}
}
}
} catch (e) {
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
}
return null;
}
/// HTML에서 Place URL 추출 (og:url 메타 태그)
static String? extractPlaceLink(String html) {
try {
// og:url 메타 태그에서 추출
final ogUrlRegex = RegExp(
r'<meta[^>]+property="og:url"[^>]+content="([^"]+)"',
caseSensitive: false,
);
final match = ogUrlRegex.firstMatch(html);
if (match != null) {
final url = match.group(1);
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
return url;
}
// canonical 링크 태그에서 추출
final canonicalRegex = RegExp(
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
caseSensitive: false,
);
final canonicalMatch = canonicalRegex.firstMatch(html);
if (canonicalMatch != null) {
final url = canonicalMatch.group(1);
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
return url;
}
} catch (e) {
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
}
return null;
}
}

View File

@@ -0,0 +1,305 @@
import 'package:html/dom.dart';
import 'package:flutter/foundation.dart';
/// 네이버 지도 HTML 파서
///
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
class NaverHtmlParser {
// CSS 셀렉터 상수
static const List<String> _nameSelectors = [
'span.GHAhO',
'h1.Qpe7b',
'span.Fc1rA',
'[class*="place_name"]',
'meta[property="og:title"]',
];
static const List<String> _categorySelectors = [
'span.DJJvD',
'span.lnJFt',
'[class*="category"]',
];
static const List<String> _descriptionSelectors = [
'span.IH7VW',
'div.vV_z_',
'meta[name="description"]',
];
static const List<String> _phoneSelectors = [
'span.xlx7Q',
'a[href^="tel:"]',
'[class*="phone"]',
];
static const List<String> _addressSelectors = [
'span.IH7VW',
'span.jWDO_',
'[class*="address"]',
];
static const List<String> _businessHoursSelectors = [
'time.aT6WB',
'div.O8qbU',
'[class*="business"]',
'[class*="hours"]',
];
/// HTML 문서에서 식당 정보 추출
Map<String, dynamic> parseRestaurantInfo(Document document) {
return {
'name': extractName(document),
'category': extractCategory(document),
'subCategory': extractSubCategory(document),
'description': extractDescription(document),
'phone': extractPhoneNumber(document),
'roadAddress': extractRoadAddress(document),
'address': extractJibunAddress(document),
'latitude': extractLatitude(document),
'longitude': extractLongitude(document),
'businessHours': extractBusinessHours(document),
};
}
/// 식당 이름 추출
String? extractName(Document document) {
try {
for (final selector in _nameSelectors) {
final element = document.querySelector(selector);
if (element != null) {
if (element.localName == 'meta') {
return element.attributes['content'];
}
final text = element.text.trim();
if (text.isNotEmpty) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
return null;
}
}
/// 카테고리 추출
String? extractCategory(Document document) {
try {
for (final selector in _categorySelectors) {
final element = document.querySelector(selector);
if (element != null) {
final text = element.text.trim();
if (text.isNotEmpty) {
// 첫 번째 카테고리만 추출 (예: "한식 > 국밥" -> "한식")
return text.split('>').first.trim();
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
return null;
}
}
/// 서브 카테고리 추출
String? extractSubCategory(Document document) {
try {
final element = document.querySelector('span.DJJvD, span.lnJFt');
if (element != null) {
final text = element.text.trim();
if (text.contains('>')) {
// 두 번째 카테고리 반환 (예: "한식 > 국밥" -> "국밥")
return text.split('>').last.trim();
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
return null;
}
}
/// 설명 추출
String? extractDescription(Document document) {
try {
for (final selector in _descriptionSelectors) {
final element = document.querySelector(selector);
if (element != null) {
if (element.localName == 'meta') {
return element.attributes['content'];
}
final text = element.text.trim();
if (text.isNotEmpty) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
return null;
}
}
/// 전화번호 추출
String? extractPhoneNumber(Document document) {
try {
for (final selector in _phoneSelectors) {
final element = document.querySelector(selector);
if (element != null) {
if (element.localName == 'a' && element.attributes['href'] != null) {
return element.attributes['href']!.replaceFirst('tel:', '');
}
final text = element.text.trim();
if (text.isNotEmpty && RegExp(r'[\d\-\+\(\)]+').hasMatch(text)) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
return null;
}
}
/// 도로명 주소 추출
String? extractRoadAddress(Document document) {
try {
for (final selector in _addressSelectors) {
final elements = document.querySelectorAll(selector);
for (final element in elements) {
final text = element.text.trim();
// 도로명 주소 패턴 확인
if (text.contains('') || text.contains('')) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
return null;
}
}
/// 지번 주소 추출
String? extractJibunAddress(Document document) {
try {
for (final selector in _addressSelectors) {
final elements = document.querySelectorAll(selector);
for (final element in elements) {
final text = element.text.trim();
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
!text.contains('') &&
!text.contains('')) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
return null;
}
}
/// 위도 추출
double? extractLatitude(Document document) {
try {
// 메타 태그에서 좌표 정보 찾기
final metaElement = document.querySelector('meta[property="og:url"]');
if (metaElement != null) {
final content = metaElement.attributes['content'];
if (content != null) {
// URL에서 좌표 파라미터 추출 (예: ?y=37.5666805)
final RegExp latRegex = RegExp(r'[?&]y=(\d+\.\d+)');
final match = latRegex.firstMatch(content);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
}
// 자바스크립트 변수에서 추출 시도
final scripts = document.querySelectorAll('script');
for (final script in scripts) {
final content = script.text;
if (content.contains('latitude') || content.contains('lat')) {
final RegExp latRegex = RegExp(r'(?:latitude|lat)["\s:]+(\d+\.\d+)');
final match = latRegex.firstMatch(content);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
return null;
}
}
/// 경도 추출
double? extractLongitude(Document document) {
try {
// 메타 태그에서 좌표 정보 찾기
final metaElement = document.querySelector('meta[property="og:url"]');
if (metaElement != null) {
final content = metaElement.attributes['content'];
if (content != null) {
// URL에서 좌표 파라미터 추출 (예: ?x=126.9784147)
final RegExp lonRegex = RegExp(r'[?&]x=(\d+\.\d+)');
final match = lonRegex.firstMatch(content);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
}
// 자바스크립트 변수에서 추출 시도
final scripts = document.querySelectorAll('script');
for (final script in scripts) {
final content = script.text;
if (content.contains('longitude') || content.contains('lng')) {
final RegExp lonRegex = RegExp(r'(?:longitude|lng)["\s:]+(\d+\.\d+)');
final match = lonRegex.firstMatch(content);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
return null;
}
}
/// 영업시간 추출
String? extractBusinessHours(Document document) {
try {
for (final selector in _businessHoursSelectors) {
final elements = document.querySelectorAll(selector);
for (final element in elements) {
final text = element.text.trim();
if (text.isNotEmpty &&
(text.contains('') ||
text.contains(':') ||
text.contains('영업'))) {
return text;
}
}
}
return null;
} catch (e) {
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
return null;
}
}
}

View File

@@ -0,0 +1,669 @@
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 '../../api/naver/naver_graphql_queries.dart';
import '../../../core/utils/category_mapper.dart';
/// 네이버 지도 URL 파서
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
/// NaverApiClient를 사용하여 네트워크 통신을 처리합니다.
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 _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})
: _apiClient = apiClient ?? NaverApiClient();
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
///
/// 지원하는 URL 형식:
/// - https://map.naver.com/p/restaurant/1234567890
/// - https://naver.me/abcdefgh
///
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
/// 가장 가까운 위치의 식당을 선택합니다.
Future<Restaurant> parseRestaurantFromUrl(
String url, {
double? userLatitude,
double? userLongitude,
}) async {
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) {
// 짧은 URL에서 직접 ID 추출 시도
final shortUrlId = _extractShortUrlId(url);
if (shortUrlId != null) {
if (kDebugMode) {
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);
if (kDebugMode) {
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
}
return restaurant;
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e');
}
// 실패 시 기본 파싱으로 계속 진행
}
}
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
final restaurantData = await _fetchRestaurantFromGraphQL(
placeId,
userLatitude: userLatitude,
userLongitude: userLongitude,
);
return _createRestaurant(restaurantData, placeId, finalUrl);
} catch (e) {
if (e is NaverMapParseException) {
rethrow;
}
if (e is RateLimitException) {
rethrow;
}
if (e is NetworkException) {
throw NaverMapParseException('네트워크 오류: ${e.message}');
}
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
}
}
/// URL이 유효한 네이버 지도 URL인지 확인
bool _isValidNaverUrl(String url) {
try {
final Uri uri = Uri.parse(url);
return uri.host.contains('naver.com') || uri.host.contains('naver.me');
} catch (e) {
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 {
final match = _shortUrlRegex.firstMatch(url);
return match?.group(1);
} catch (e) {
return null;
}
}
/// GraphQL API로 식당 정보 가져오기
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
String placeId, {
double? userLatitude,
double? userLongitude,
}) async {
// 심플한 접근: URL로 직접 검색
try {
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 방지
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}');
}
return _convertSearchResultToData(result);
}
}
// 정확한 매칭이 없으면 첫 번째 결과 사용
if (kDebugMode) {
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
}
return _convertSearchResultToData(searchResults.first);
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: URL 검색 실패 - $e');
}
}
// Step 2: Place ID로 검색
try {
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}');
}
return _convertSearchResultToData(searchResults.first);
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
}
// 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) {
throw RateLimitException(
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
originalError: e,
);
}
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
}
// 429 에러인 경우 즉시 예외 발생
if (e is DioException && e.response?.statusCode == 429) {
throw RateLimitException(
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
originalError: e,
);
}
}
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
// 첫 번째 시도: places 쿼리
try {
if (kDebugMode) {
debugPrint('NaverMapParser: Trying places query...');
}
final response = await _apiClient.fetchGraphQL(
operationName: 'getPlaceDetail',
variables: {'id': placeId},
query: NaverGraphQLQueries.placeDetailQuery,
);
// places 응답 처리 (배열일 수도 있음)
final placesData = response['data']?['places'];
if (placesData != null) {
if (placesData is List && placesData.isNotEmpty) {
return _extractPlaceData(placesData.first as Map<String, dynamic>);
} else if (placesData is Map) {
return _extractPlaceData(placesData as Map<String, dynamic>);
}
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: places query failed - $e');
}
}
// 두 번째 시도: nxPlaces 쿼리
try {
if (kDebugMode) {
debugPrint('NaverMapParser: Trying nxPlaces query...');
}
final response = await _apiClient.fetchGraphQL(
operationName: 'getPlaceDetail',
variables: {'id': placeId},
query: NaverGraphQLQueries.nxPlaceDetailQuery,
);
// nxPlaces 응답 처리 (배열일 수도 있음)
final nxPlacesData = response['data']?['nxPlaces'];
if (nxPlacesData != null) {
if (nxPlacesData is List && nxPlacesData.isNotEmpty) {
return _extractPlaceData(nxPlacesData.first as Map<String, dynamic>);
} else if (nxPlacesData is Map) {
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
}
}
} catch (e) {
if (kDebugMode) {
debugPrint('NaverMapParser: nxPlaces query failed - $e');
}
}
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
if (kDebugMode) {
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 category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
return {
'name': item.title,
'category': category,
'subCategory': subCategory,
'address': item.address,
'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에서는 영업시간 정보 제공 안 함
};
}
/// 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();
category = categoryParts.isNotEmpty ? categoryParts.first : null;
subCategory = categoryParts.length > 1 ? categoryParts.last : null;
}
return {
'name': placeData['name'],
'category': category,
'subCategory': subCategory,
'address': placeData['address'],
'roadAddress': placeData['roadAddress'],
'phone': placeData['phone'] ?? placeData['virtualPhone'],
'description': placeData['description'],
'latitude': placeData['location']?['lat'],
'longitude': placeData['location']?['lng'],
'businessHours': placeData['businessHours']?.isNotEmpty == true
? placeData['businessHours'][0]['description']
: 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,
);
}
rethrow;
}
}
/// Restaurant 객체 생성
Restaurant _createRestaurant(
Map<String, dynamic> data,
String placeId,
String url,
) {
// 데이터 추출 및 기본값 처리
final String name = data['name'] ?? '네이버 지도 장소 #$placeId';
final String rawCategory = data['category'] ?? '음식점';
final String? rawSubCategory = data['subCategory'];
final String? description = data['description'];
final String? phoneNumber = data['phone'];
final String roadAddress = data['roadAddress'] ?? '';
final String jibunAddress = data['address'] ?? '';
final double? latitude = data['latitude'];
final double? longitude = data['longitude'];
final String? businessHours = data['businessHours'];
// 카테고리 정규화
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 : '주소 정보를 가져올 수 없습니다';
return Restaurant(
id: _uuid.v4(),
name: name,
category: normalizedCategory,
subCategory: finalSubCategory,
description: description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
phoneNumber: phoneNumber,
roadAddress: finalRoadAddress,
jibunAddress: finalJibunAddress,
latitude: finalLatitude,
longitude: finalLongitude,
lastVisitDate: null,
source: DataSource.NAVER,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
naverPlaceId: placeId,
naverUrl: url,
businessHours: businessHours,
lastVisited: null,
visitCount: 0,
);
}
/// 기본 정보로 Restaurant 생성 (Fallback)
Restaurant _createFallbackRestaurant(String placeId, String url) {
return Restaurant(
id: _uuid.v4(),
name: '네이버 지도 장소 #$placeId',
category: '음식점',
subCategory: '음식점',
description: '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
phoneNumber: null,
roadAddress: '주소 정보를 가져올 수 없습니다',
jibunAddress: '주소 정보를 가져올 수 없습니다',
latitude: _defaultLatitude,
longitude: _defaultLongitude,
lastVisitDate: null,
source: DataSource.NAVER,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
naverPlaceId: placeId,
naverUrl: url,
businessHours: null,
lastVisited: null,
visitCount: 0,
);
}
/// 단축 URL을 위한 향상된 파싱 메서드
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
Future<Restaurant> _parseWithLocalSearch(
String placeId,
String finalUrl,
double? userLatitude,
double? userLongitude,
) async {
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) {
searchQuery = koreanData['jsonLdName'] as String;
if (kDebugMode) {
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
}
} else if (koreanData['apolloStateName'] != null) {
searchQuery = koreanData['apolloStateName'] as String;
if (kDebugMode) {
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
}
} else if (koreanTexts.isNotEmpty) {
searchQuery = koreanTexts.first as String;
if (kDebugMode) {
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
}
} else {
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
}
// 2. 로컬 검색 API 호출
if (kDebugMode) {
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
}
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
final searchResults = await _apiClient.searchLocal(
query: searchQuery,
latitude: userLatitude,
longitude: userLongitude,
display: 20, // 더 많은 결과 검색
);
if (searchResults.isEmpty) {
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
}
// 디버깅: 검색 결과 Place ID 분석
if (kDebugMode) {
debugPrint('=== 로컬 검색 결과 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)');
}
debugPrint('=====================================');
}
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
NaverLocalSearchResult? bestMatch;
// 1차: Place ID가 정확히 일치하는 결과 찾기
for (final result in searchResults) {
final extractedId = result.extractPlaceId();
if (extractedId == placeId) {
bestMatch = result;
if (kDebugMode) {
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
}
break;
}
}
// 2차: 상호명이 유사한 결과 찾기
if (bestMatch == null) {
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
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) ||
exactName.contains(result.title)) {
bestMatch = result;
if (kDebugMode) {
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
}
break;
}
}
}
}
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
if (bestMatch == null && userLatitude != null && userLongitude != null) {
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
if (bestMatch != null && kDebugMode) {
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
}
}
// 최종: 첫 번째 결과 사용
if (bestMatch == null) {
bestMatch = searchResults.first;
if (kDebugMode) {
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
}
}
// 4. Restaurant 객체 생성
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
// 추가 정보 보완
return restaurant.copyWith(
naverPlaceId: placeId,
naverUrl: finalUrl,
source: DataSource.NAVER,
updatedAt: DateTime.now(),
);
}
/// 가장 가까운 결과 찾기 (거리 기반)
NaverLocalSearchResult? _findNearestResult(
List<NaverLocalSearchResult> results,
double userLat,
double userLng,
) {
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)');
}
return nearest;
}
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
///
/// 반환값: 킬로미터 단위의 거리
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)) +
(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() {
_apiClient.dispose();
}
}
/// 네이버 지도 파싱 예외
class NaverMapParseException implements Exception {
final String message;
NaverMapParseException(this.message);
@override
String toString() => 'NaverMapParseException: $message';
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/foundation.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 'naver_map_parser.dart';
/// 네이버 검색 서비스
///
/// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다.
class NaverSearchService {
final NaverApiClient _apiClient;
final NaverMapParser _mapParser;
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);
/// URL에서 식당 정보 가져오기
///
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
///
/// [url] 네이버 지도 URL 또는 단축 URL
///
/// Throws:
/// - [NaverMapParseException] URL 파싱 실패 시
/// - [NetworkException] 네트워크 오류 발생 시
Future<Restaurant> getRestaurantFromUrl(String url) async {
try {
return await _mapParser.parseRestaurantFromUrl(url);
} catch (e) {
if (e is NaverMapParseException || e is NetworkException) {
rethrow;
}
throw ParseException(
message: '식당 정보를 가져올 수 없습니다: $e',
originalError: e,
);
}
}
/// 키워드로 주변 식당 검색
///
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
Future<List<Restaurant>> searchNearbyRestaurants({
required String query,
double? latitude,
double? longitude,
int maxResults = 20,
String sort = 'random', // random, comment
}) async {
try {
final searchResults = await _apiClient.searchLocal(
query: query,
latitude: latitude,
longitude: longitude,
display: maxResults,
sort: sort,
);
return searchResults
.map((result) => result.toRestaurant(id: _uuid.v4()))
.toList();
} catch (e) {
if (e is NetworkException) {
rethrow;
}
throw ParseException(
message: '식당 검색에 실패했습니다: $e',
originalError: e,
);
}
}
/// 식당 이름으로 상세 정보 검색
///
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
Future<Restaurant?> searchRestaurantDetails({
required String name,
String? address,
double? latitude,
double? longitude,
}) async {
try {
// 검색어 구성
String query = name;
if (address != null && address.isNotEmpty) {
// 주소에서 시/구 정보 추출
final addressParts = address.split(' ');
if (addressParts.length >= 2) {
query = '${addressParts[0]} ${addressParts[1]} $name';
}
}
final searchResults = await _apiClient.searchLocal(
query: query,
latitude: latitude,
longitude: longitude,
display: 5,
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
);
if (searchResults.isEmpty) {
return null;
}
// 가장 유사한 결과 찾기
final bestMatch = _findBestMatch(name, searchResults);
if (bestMatch != null) {
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
// 네이버 지도 URL이 있으면 상세 정보 파싱 시도
if (restaurant.naverUrl != null) {
try {
final detailedRestaurant = await _mapParser.parseRestaurantFromUrl(
restaurant.naverUrl!,
);
// 기존 정보와 병합
return Restaurant(
id: restaurant.id,
name: restaurant.name,
category: restaurant.category,
subCategory: restaurant.subCategory,
description: detailedRestaurant.description ?? restaurant.description,
phoneNumber: restaurant.phoneNumber,
roadAddress: restaurant.roadAddress,
jibunAddress: restaurant.jibunAddress,
latitude: restaurant.latitude,
longitude: restaurant.longitude,
lastVisitDate: restaurant.lastVisitDate,
source: restaurant.source,
createdAt: restaurant.createdAt,
updatedAt: DateTime.now(),
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
naverUrl: restaurant.naverUrl,
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
lastVisited: restaurant.lastVisited,
visitCount: restaurant.visitCount,
);
} catch (e) {
// 상세 파싱 실패해도 기본 정보 반환
if (kDebugMode) {
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
}
}
}
return restaurant;
}
return null;
} catch (e) {
if (e is NetworkException) {
rethrow;
}
throw ParseException(
message: '식당 상세 정보 검색에 실패했습니다: $e',
originalError: e,
);
}
}
/// 가장 유사한 검색 결과 찾기
NaverLocalSearchResult? _findBestMatch(
String targetName,
List<NaverLocalSearchResult> results,
) {
if (results.isEmpty) return null;
// 정확히 일치하는 결과 우선
final exactMatch = results.firstWhere(
(result) => result.title.toLowerCase() == targetName.toLowerCase(),
orElse: () => results.first,
);
if (exactMatch.title.toLowerCase() == targetName.toLowerCase()) {
return exactMatch;
}
// 유사도 계산 (간단한 버전)
NaverLocalSearchResult? bestMatch;
double bestScore = 0.0;
for (final result in results) {
final score = _calculateSimilarity(targetName, result.title);
if (score > bestScore) {
bestScore = score;
bestMatch = result;
}
}
// 유사도가 너무 낮으면 null 반환
if (bestScore < 0.5) {
return null;
}
return bestMatch ?? results.first;
}
/// 문자열 유사도 계산 (Jaccard 유사도)
double _calculateSimilarity(String str1, String str2) {
final s1 = str1.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
final s2 = str2.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
if (s1.isEmpty || s2.isEmpty) return 0.0;
// 포함 관계 확인
if (s1.contains(s2) || s2.contains(s1)) {
return 0.8;
}
// 문자 집합으로 변환
final set1 = s1.split('').toSet();
final set2 = s2.split('').toSet();
// Jaccard 유사도 계산
final intersection = set1.intersection(set2).length;
final union = set1.union(set2).length;
return union > 0 ? intersection / union : 0.0;
}
/// 리소스 정리
void dispose() {
_apiClient.dispose();
_mapParser.dispose();
}
// 테스트를 위한 내부 메서드 접근
@visibleForTesting
NaverLocalSearchResult? findBestMatchForTesting(
String targetName,
List<NaverLocalSearchResult> results,
) {
return _findBestMatch(targetName, results);
}
@visibleForTesting
double calculateSimilarityForTesting(String str1, String str2) {
return _calculateSimilarity(str1, str2);
}
}