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:
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal file
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user