467 lines
11 KiB
Dart
467 lines
11 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;
|
|
}
|
|
}
|