LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
253 lines
9.8 KiB
Dart
253 lines
9.8 KiB
Dart
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;
|
|
}
|
|
} |