Files
lunchpick/lib/data/datasources/remote/naver_html_extractor.dart
JiWoong Sul 2a01fa50c6 feat(app): finalize ad gated flows and weather
- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
2025-11-22 00:10:51 +09:00

482 lines
11 KiB
Dart

import 'dart:convert';
import 'package:lunchpick/core/utils/app_logger.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();
AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 ==========');
for (int i = 0; i < resultList.length; i++) {
AppLogger.debug('[$i] ${resultList[i]}');
}
AppLogger.debug('========== 총 ${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, stackTrace) {
AppLogger.error(
'NaverHtmlExtractor: JSON-LD 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
}
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, stackTrace) {
AppLogger.error(
'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e',
error: e,
stackTrace: stackTrace,
);
}
}
}
} catch (e, stackTrace) {
AppLogger.error(
'NaverHtmlExtractor: Apollo State 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
}
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);
AppLogger.debug('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);
AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url');
return url;
}
} catch (e, stackTrace) {
AppLogger.error(
'NaverHtmlExtractor: Place Link 추출 실패 - $e',
error: e,
stackTrace: stackTrace,
);
}
return null;
}
}