- 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
346 lines
9.8 KiB
Dart
346 lines
9.8 KiB
Dart
import 'package:html/dom.dart';
|
|
import 'package:lunchpick/core/utils/app_logger.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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 이름 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 카테고리 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 서브 카테고리 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 설명 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 전화번호 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 도로명 주소 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 지번 주소 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 위도 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 경도 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
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, stackTrace) {
|
|
AppLogger.error(
|
|
'NaverHtmlParser: 영업시간 추출 실패 - $e',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
}
|