feat: SMS 스캔 화면 리팩토링 및 MVC 패턴 적용
- SMS 스캔 화면을 컨트롤러/서비스/위젯으로 분리 - 코드 가독성 및 유지보수성 향상 - 새로운 다국어 지원 키 추가 - Git 커밋 가이드라인 문서화
This commit is contained in:
79
lib/services/sms_scan/subscription_converter.dart
Normal file
79
lib/services/sms_scan/subscription_converter.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import '../../models/subscription.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
|
||||
class SubscriptionConverter {
|
||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) {
|
||||
final result = <Subscription>[];
|
||||
|
||||
for (var model in models) {
|
||||
try {
|
||||
final subscription = _convertSingle(model);
|
||||
result.add(subscription);
|
||||
|
||||
print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
||||
} catch (e) {
|
||||
print('모델 변환 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 단일 모델 변환
|
||||
Subscription _convertSingle(SubscriptionModel model) {
|
||||
return Subscription(
|
||||
id: model.id,
|
||||
serviceName: model.serviceName,
|
||||
monthlyCost: model.monthlyCost,
|
||||
billingCycle: _denormalizeBillingCycle(model.billingCycle), // 영어 -> 한국어
|
||||
nextBillingDate: model.nextBillingDate,
|
||||
category: model.categoryId, // categoryId를 category로 매핑
|
||||
repeatCount: model.repeatCount > 0 ? model.repeatCount : 1,
|
||||
lastPaymentDate: model.lastPaymentDate,
|
||||
websiteUrl: model.websiteUrl,
|
||||
currency: model.currency,
|
||||
);
|
||||
}
|
||||
|
||||
// billingCycle 역정규화 (영어 -> 한국어)
|
||||
String _denormalizeBillingCycle(String cycle) {
|
||||
switch (cycle.toLowerCase()) {
|
||||
case 'monthly':
|
||||
return '월간';
|
||||
case 'yearly':
|
||||
case 'annually':
|
||||
return '연간';
|
||||
case 'weekly':
|
||||
return '주간';
|
||||
case 'daily':
|
||||
return '일간';
|
||||
case 'quarterly':
|
||||
return '분기별';
|
||||
case 'semi-annually':
|
||||
return '반기별';
|
||||
default:
|
||||
return cycle; // 알 수 없는 형식은 그대로 반환
|
||||
}
|
||||
}
|
||||
|
||||
// billingCycle 정규화 (한국어 -> 영어)
|
||||
String normalizeBillingCycle(String cycle) {
|
||||
switch (cycle) {
|
||||
case '월간':
|
||||
return 'monthly';
|
||||
case '연간':
|
||||
return 'yearly';
|
||||
case '주간':
|
||||
return 'weekly';
|
||||
case '일간':
|
||||
return 'daily';
|
||||
case '분기별':
|
||||
return 'quarterly';
|
||||
case '반기별':
|
||||
return 'semi-annually';
|
||||
default:
|
||||
return 'monthly'; // 기본값
|
||||
}
|
||||
}
|
||||
}
|
||||
60
lib/services/sms_scan/subscription_filter.dart
Normal file
60
lib/services/sms_scan/subscription_filter.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import '../../models/subscription.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
|
||||
class SubscriptionFilter {
|
||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||
List<Subscription> filterDuplicates(
|
||||
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
||||
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
|
||||
// 중복되지 않은 구독만 필터링
|
||||
return scanned.where((scannedSub) {
|
||||
// 기존 구독 중에 같은 서비스명과 월 비용을 가진 것이 있는지 확인
|
||||
final isDuplicate = existing.any((existingSub) {
|
||||
final isSameName = existingSub.serviceName.toLowerCase() ==
|
||||
scannedSub.serviceName.toLowerCase();
|
||||
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
|
||||
|
||||
if (isSameName && isSameCost) {
|
||||
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return !isDuplicate;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 반복 횟수 기반 필터링
|
||||
List<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) {
|
||||
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
|
||||
}
|
||||
|
||||
// 날짜 기반 필터링 (선택적)
|
||||
List<Subscription> filterByDateRange(
|
||||
List<Subscription> subscriptions, DateTime startDate, DateTime endDate) {
|
||||
return subscriptions.where((sub) {
|
||||
return sub.nextBillingDate.isAfter(startDate) &&
|
||||
sub.nextBillingDate.isBefore(endDate);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 금액 기반 필터링 (선택적)
|
||||
List<Subscription> filterByPriceRange(
|
||||
List<Subscription> subscriptions, double minPrice, double maxPrice) {
|
||||
return subscriptions
|
||||
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 카테고리 기반 필터링 (선택적)
|
||||
List<Subscription> filterByCategories(
|
||||
List<Subscription> subscriptions, List<String> categoryIds) {
|
||||
if (categoryIds.isEmpty) return subscriptions;
|
||||
|
||||
return subscriptions.where((sub) {
|
||||
return sub.category != null && categoryIds.contains(sub.category);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,385 +1,79 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'url_matcher/models/service_info.dart';
|
||||
import 'url_matcher/data/legacy_service_data.dart';
|
||||
|
||||
// ServiceInfo를 외부에서 접근 가능하도록 export
|
||||
export 'url_matcher/models/service_info.dart';
|
||||
|
||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
|
||||
import 'url_matcher/models/service_info.dart';
|
||||
import 'url_matcher/data/service_data_repository.dart';
|
||||
import 'url_matcher/services/url_matcher_service.dart';
|
||||
import 'url_matcher/services/category_mapper_service.dart';
|
||||
import 'url_matcher/services/cancellation_url_service.dart';
|
||||
import 'url_matcher/services/service_name_resolver.dart';
|
||||
import 'url_matcher/services/sms_extractor_service.dart';
|
||||
|
||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 (Facade 패턴)
|
||||
class SubscriptionUrlMatcher {
|
||||
static Map<String, dynamic>? _servicesData;
|
||||
static bool _isInitialized = false;
|
||||
static ServiceDataRepository? _dataRepository;
|
||||
static UrlMatcherService? _urlMatcher;
|
||||
static CategoryMapperService? _categoryMapper;
|
||||
static CancellationUrlService? _cancellationService;
|
||||
static ServiceNameResolver? _nameResolver;
|
||||
static SmsExtractorService? _smsExtractor;
|
||||
|
||||
/// JSON 데이터 초기화
|
||||
/// 서비스 초기화
|
||||
static Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
if (_dataRepository != null && _dataRepository!.isInitialized) return;
|
||||
|
||||
try {
|
||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
_servicesData = json.decode(jsonString);
|
||||
_isInitialized = true;
|
||||
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
|
||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
||||
_isInitialized = true;
|
||||
}
|
||||
// 1. 데이터 저장소 초기화
|
||||
_dataRepository = ServiceDataRepository();
|
||||
await _dataRepository!.initialize();
|
||||
|
||||
// 2. 서비스 초기화
|
||||
_categoryMapper = CategoryMapperService(_dataRepository!);
|
||||
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
|
||||
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||
_nameResolver = ServiceNameResolver(_dataRepository!);
|
||||
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
|
||||
}
|
||||
|
||||
/// 도메인 추출 (www와 TLD 제외)
|
||||
static String? extractDomain(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final host = uri.host.toLowerCase();
|
||||
|
||||
// 도메인 부분 추출
|
||||
var parts = host.split('.');
|
||||
|
||||
// www 제거
|
||||
if (parts.isNotEmpty && parts[0] == 'www') {
|
||||
parts = parts.sublist(1);
|
||||
}
|
||||
|
||||
// 서브도메인 처리 (예: music.youtube.com)
|
||||
if (parts.length >= 3) {
|
||||
// 서브도메인 포함 전체 도메인 반환
|
||||
return parts.sublist(0, parts.length - 1).join('.');
|
||||
} else if (parts.length >= 2) {
|
||||
// 메인 도메인만 반환
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
return _urlMatcher?.extractDomain(url);
|
||||
}
|
||||
|
||||
/// URL로 서비스 찾기
|
||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||
await initialize();
|
||||
|
||||
final domain = extractDomain(url);
|
||||
if (domain == null) return null;
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceEntry in services.entries) {
|
||||
final serviceId = serviceEntry.key;
|
||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
||||
|
||||
// 도메인이 일치하는지 확인
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final names = List<String>.from(serviceData['names'] ?? []);
|
||||
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: serviceId,
|
||||
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
||||
serviceUrl: urls?['kr'] ?? urls?['en'],
|
||||
cancellationUrl: null,
|
||||
categoryId: _getCategoryIdByKey(categoryId),
|
||||
categoryNameKr: categoryData['nameKr'] ?? '',
|
||||
categoryNameEn: categoryData['nameEn'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final serviceUrl = entry.value;
|
||||
final serviceDomain = extractDomain(serviceUrl);
|
||||
|
||||
if (serviceDomain != null &&
|
||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: serviceUrl,
|
||||
cancellationUrl: null,
|
||||
categoryId: _getCategoryForLegacyService(entry.key),
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return _urlMatcher?.findServiceByUrl(url);
|
||||
}
|
||||
|
||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
||||
static String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('SubscriptionUrlMatcher: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 비교
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// OTT 서비스 검사
|
||||
for (final entry in LegacyServiceData.ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 음악 서비스 검사
|
||||
for (final entry in LegacyServiceData.musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 서비스 검사
|
||||
for (final entry in LegacyServiceData.aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 서비스 검사
|
||||
for (final entry in LegacyServiceData.programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 오피스 툴 검사
|
||||
for (final entry in LegacyServiceData.officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 서비스 검사
|
||||
for (final entry in LegacyServiceData.otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final serviceWords = lowerName.split(' ');
|
||||
final keyWords = entry.key.toLowerCase().split(' ');
|
||||
|
||||
// 단어 단위로 일치하는지 확인
|
||||
for (final word in serviceWords) {
|
||||
if (word.length > 2 &&
|
||||
keyWords.any((keyWord) => keyWord.contains(word))) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 추출 가능한 도메인이 있는지 확인
|
||||
final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName);
|
||||
if (domainMatch != null && domainMatch.group(1)!.length > 2) {
|
||||
final domain = domainMatch.group(1)!.trim();
|
||||
if (domain.length > 2 &&
|
||||
!['the', 'and', 'for', 'www'].contains(domain)) {
|
||||
final url = 'https://www.$domain.com';
|
||||
print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e');
|
||||
return null;
|
||||
}
|
||||
return _urlMatcher?.suggestUrl(serviceName);
|
||||
}
|
||||
|
||||
/// 해지 안내 URL 찾기 (개선된 버전)
|
||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||
static Future<String?> findCancellationUrl({
|
||||
String? serviceName,
|
||||
String? websiteUrl,
|
||||
String locale = 'kr',
|
||||
}) async {
|
||||
await initialize();
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
// 1. 서비스명으로 찾기
|
||||
if (serviceName != null && serviceName.isNotEmpty) {
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. URL로 찾기
|
||||
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
||||
final domain = extractDomain(websiteUrl);
|
||||
if (domain != null) {
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
||||
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
||||
return _cancellationService?.findCancellationUrl(
|
||||
serviceName: serviceName,
|
||||
websiteUrl: websiteUrl,
|
||||
locale: locale,
|
||||
);
|
||||
}
|
||||
|
||||
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
|
||||
static String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
|
||||
if (serviceNameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
|
||||
|
||||
// 직접 서비스명으로 찾기
|
||||
if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) {
|
||||
return LegacyServiceData.cancellationUrls[lowerText];
|
||||
}
|
||||
|
||||
// 서비스명에 부분 포함으로 찾기
|
||||
for (var entry in LegacyServiceData.cancellationUrls.entries) {
|
||||
final String key = entry.key.toLowerCase();
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// URL을 통해 서비스명 추출 후 찾기
|
||||
if (lowerText.startsWith('http')) {
|
||||
// URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출)
|
||||
final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)');
|
||||
final match = domainRegex.firstMatch(lowerText);
|
||||
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
final domain = match.group(1)?.toLowerCase() ?? '';
|
||||
|
||||
// 도메인으로 서비스명 찾기
|
||||
for (var entry in LegacyServiceData.cancellationUrls.entries) {
|
||||
if (entry.key.toLowerCase().contains(domain)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 해지 안내 페이지를 찾지 못함
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||
// 새로운 JSON 기반 방식으로 확인
|
||||
final cancellationUrl = await findCancellationUrl(
|
||||
serviceName: serviceNameOrUrl,
|
||||
websiteUrl: serviceNameOrUrl,
|
||||
);
|
||||
return cancellationUrl != null;
|
||||
await initialize();
|
||||
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
|
||||
}
|
||||
|
||||
/// 서비스명으로 카테고리 찾기
|
||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||
await initialize();
|
||||
if (serviceName.isEmpty) return null;
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
return _getCategoryIdByKey(categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
||||
return _getCategoryForLegacyService(serviceName);
|
||||
return _categoryMapper?.findCategoryByServiceName(serviceName);
|
||||
}
|
||||
|
||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||
@@ -388,189 +82,26 @@ class SubscriptionUrlMatcher {
|
||||
required String locale,
|
||||
}) async {
|
||||
await initialize();
|
||||
|
||||
if (_servicesData == null) {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
// JSON에서 서비스 찾기
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final data = serviceData as Map<String, dynamic>;
|
||||
final names = List<String>.from(data['names'] ?? []);
|
||||
|
||||
// names 배열에 있는지 확인
|
||||
for (final name in names) {
|
||||
if (lowerName == name.toLowerCase() ||
|
||||
lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
// 로케일에 따라 적절한 이름 반환
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nameKr/nameEn에 직접 매칭 확인
|
||||
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
||||
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
||||
|
||||
if (lowerName == nameKr || lowerName == nameEn) {
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못한 경우 원래 이름 반환
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
||||
static String _getCategoryIdByKey(String key) {
|
||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
||||
// 임시로 카테고리명 기반 매핑
|
||||
switch (key) {
|
||||
case 'music':
|
||||
return 'music_streaming';
|
||||
case 'ott':
|
||||
return 'ott_services';
|
||||
case 'storage':
|
||||
return 'cloud_storage';
|
||||
case 'ai':
|
||||
return 'ai_services';
|
||||
case 'programming':
|
||||
return 'dev_tools';
|
||||
case 'office':
|
||||
return 'office_tools';
|
||||
case 'lifestyle':
|
||||
return 'lifestyle';
|
||||
case 'shopping':
|
||||
return 'shopping';
|
||||
case 'gaming':
|
||||
return 'gaming';
|
||||
case 'telecom':
|
||||
return 'telecom';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
/// 레거시 서비스명으로 카테고리 추측
|
||||
static String _getCategoryForLegacyService(String serviceName) {
|
||||
final lowerName = serviceName.toLowerCase();
|
||||
|
||||
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services';
|
||||
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming';
|
||||
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage';
|
||||
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services';
|
||||
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools';
|
||||
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools';
|
||||
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle';
|
||||
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom';
|
||||
|
||||
return 'other';
|
||||
return await _nameResolver?.getServiceDisplayName(
|
||||
serviceName: serviceName,
|
||||
locale: locale,
|
||||
) ?? serviceName;
|
||||
}
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||
await initialize();
|
||||
|
||||
// URL 패턴 찾기
|
||||
final urlPattern = RegExp(
|
||||
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final matches = urlPattern.allMatches(smsText);
|
||||
|
||||
for (final match in matches) {
|
||||
final url = match.group(0);
|
||||
if (url != null) {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
if (serviceInfo != null) {
|
||||
return serviceInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL로 못 찾았으면 서비스명으로 시도
|
||||
final lowerSms = smsText.toLowerCase();
|
||||
|
||||
// 모든 서비스명 검사
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerSms.contains(entry.key.toLowerCase())) {
|
||||
final categoryId = await findCategoryByServiceName(entry.key) ?? 'other';
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: entry.value,
|
||||
cancellationUrl: null,
|
||||
categoryId: categoryId,
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return _smsExtractor?.extractServiceFromSms(smsText);
|
||||
}
|
||||
|
||||
/// URL이 알려진 서비스 URL인지 확인
|
||||
static Future<bool> isKnownServiceUrl(String url) async {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
return serviceInfo != null;
|
||||
await initialize();
|
||||
return await _urlMatcher?.isKnownServiceUrl(url) ?? false;
|
||||
}
|
||||
|
||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
|
||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
// 입력 텍스트가 비어있거나 null인 경우
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 정확히 일치하는 경우
|
||||
if (LegacyServiceData.allServices.containsKey(lowerText)) {
|
||||
return LegacyServiceData.allServices[lowerText];
|
||||
}
|
||||
|
||||
// 부분 일치 검색이 활성화된 경우
|
||||
if (usePartialMatch) {
|
||||
// 가장 긴 부분 매칭 찾기
|
||||
String? bestMatch;
|
||||
int maxLength = 0;
|
||||
|
||||
for (var entry in LegacyServiceData.allServices.entries) {
|
||||
final String key = entry.key;
|
||||
|
||||
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
// 더 긴 매칭을 우선시
|
||||
if (key.length > maxLength) {
|
||||
maxLength = key.length;
|
||||
bestMatch = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
|
||||
}
|
||||
}
|
||||
30
lib/services/url_matcher/data/service_data_repository.dart
Normal file
30
lib/services/url_matcher/data/service_data_repository.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 서비스 데이터를 관리하는 저장소 클래스
|
||||
class ServiceDataRepository {
|
||||
Map<String, dynamic>? _servicesData;
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// JSON 데이터 초기화
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
_servicesData = json.decode(jsonString);
|
||||
_isInitialized = true;
|
||||
print('ServiceDataRepository: JSON 데이터 로드 완료');
|
||||
} catch (e) {
|
||||
print('ServiceDataRepository: JSON 로드 실패 - $e');
|
||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 서비스 데이터 가져오기
|
||||
Map<String, dynamic>? getServicesData() => _servicesData;
|
||||
|
||||
/// 초기화 여부 확인
|
||||
bool get isInitialized => _isInitialized;
|
||||
}
|
||||
129
lib/services/url_matcher/services/cancellation_url_service.dart
Normal file
129
lib/services/url_matcher/services/cancellation_url_service.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import '../data/service_data_repository.dart';
|
||||
import '../data/legacy_service_data.dart';
|
||||
import 'url_matcher_service.dart';
|
||||
|
||||
/// 해지 URL 관련 기능을 제공하는 서비스 클래스
|
||||
class CancellationUrlService {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
final UrlMatcherService _urlMatcher;
|
||||
|
||||
CancellationUrlService(this._dataRepository, this._urlMatcher);
|
||||
|
||||
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
|
||||
Future<String?> findCancellationUrl({
|
||||
String? serviceName,
|
||||
String? websiteUrl,
|
||||
String locale = 'kr',
|
||||
}) async {
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
final servicesData = _dataRepository.getServicesData();
|
||||
if (servicesData != null) {
|
||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||
|
||||
// 1. 서비스명으로 찾기
|
||||
if (serviceName != null && serviceName.isNotEmpty) {
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. URL로 찾기
|
||||
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
||||
final domain = _urlMatcher.extractDomain(websiteUrl);
|
||||
if (domain != null) {
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
||||
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
||||
}
|
||||
|
||||
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
|
||||
String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
|
||||
if (serviceNameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
|
||||
|
||||
// 직접 서비스명으로 찾기
|
||||
if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) {
|
||||
return LegacyServiceData.cancellationUrls[lowerText];
|
||||
}
|
||||
|
||||
// 서비스명에 부분 포함으로 찾기
|
||||
for (var entry in LegacyServiceData.cancellationUrls.entries) {
|
||||
final String key = entry.key.toLowerCase();
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// URL을 통해 서비스명 추출 후 찾기
|
||||
if (lowerText.startsWith('http')) {
|
||||
// URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출)
|
||||
final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)');
|
||||
final match = domainRegex.firstMatch(lowerText);
|
||||
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
final domain = match.group(1)?.toLowerCase() ?? '';
|
||||
|
||||
// 도메인으로 서비스명 찾기
|
||||
for (var entry in LegacyServiceData.cancellationUrls.entries) {
|
||||
if (entry.key.toLowerCase().contains(domain)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 해지 안내 페이지를 찾지 못함
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||
// 새로운 JSON 기반 방식으로 확인
|
||||
final cancellationUrl = await findCancellationUrl(
|
||||
serviceName: serviceNameOrUrl,
|
||||
websiteUrl: serviceNameOrUrl,
|
||||
);
|
||||
return cancellationUrl != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import '../data/service_data_repository.dart';
|
||||
import '../data/legacy_service_data.dart';
|
||||
|
||||
/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스
|
||||
class CategoryMapperService {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
|
||||
CategoryMapperService(this._dataRepository);
|
||||
|
||||
/// 서비스명으로 카테고리 찾기
|
||||
Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||
if (serviceName.isEmpty) return null;
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
final servicesData = _dataRepository.getServicesData();
|
||||
if (servicesData != null) {
|
||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
return getCategoryIdByKey(categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
||||
return getCategoryForLegacyService(serviceName);
|
||||
}
|
||||
|
||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
||||
String getCategoryIdByKey(String key) {
|
||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
||||
// 임시로 카테고리명 기반 매핑
|
||||
switch (key) {
|
||||
case 'music':
|
||||
return 'music_streaming';
|
||||
case 'ott':
|
||||
return 'ott_services';
|
||||
case 'storage':
|
||||
return 'cloud_storage';
|
||||
case 'ai':
|
||||
return 'ai_services';
|
||||
case 'programming':
|
||||
return 'dev_tools';
|
||||
case 'office':
|
||||
return 'office_tools';
|
||||
case 'lifestyle':
|
||||
return 'lifestyle';
|
||||
case 'shopping':
|
||||
return 'shopping';
|
||||
case 'gaming':
|
||||
return 'gaming';
|
||||
case 'telecom':
|
||||
return 'telecom';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
/// 레거시 서비스명으로 카테고리 추측
|
||||
String getCategoryForLegacyService(String serviceName) {
|
||||
final lowerName = serviceName.toLowerCase();
|
||||
|
||||
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services';
|
||||
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming';
|
||||
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage';
|
||||
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services';
|
||||
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools';
|
||||
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools';
|
||||
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle';
|
||||
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
61
lib/services/url_matcher/services/service_name_resolver.dart
Normal file
61
lib/services/url_matcher/services/service_name_resolver.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import '../data/service_data_repository.dart';
|
||||
|
||||
/// 서비스명 관련 기능을 제공하는 서비스 클래스
|
||||
class ServiceNameResolver {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
|
||||
ServiceNameResolver(this._dataRepository);
|
||||
|
||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||
Future<String> getServiceDisplayName({
|
||||
required String serviceName,
|
||||
required String locale,
|
||||
}) async {
|
||||
final servicesData = _dataRepository.getServicesData();
|
||||
if (servicesData == null) {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||
|
||||
// JSON에서 서비스 찾기
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final data = serviceData as Map<String, dynamic>;
|
||||
final names = List<String>.from(data['names'] ?? []);
|
||||
|
||||
// names 배열에 있는지 확인
|
||||
for (final name in names) {
|
||||
if (lowerName == name.toLowerCase() ||
|
||||
lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
// 로케일에 따라 적절한 이름 반환
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nameKr/nameEn에 직접 매칭 확인
|
||||
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
||||
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
||||
|
||||
if (lowerName == nameKr || lowerName == nameEn) {
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못한 경우 원래 이름 반환
|
||||
return serviceName;
|
||||
}
|
||||
}
|
||||
55
lib/services/url_matcher/services/sms_extractor_service.dart
Normal file
55
lib/services/url_matcher/services/sms_extractor_service.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import '../models/service_info.dart';
|
||||
import '../data/legacy_service_data.dart';
|
||||
import 'url_matcher_service.dart';
|
||||
import 'category_mapper_service.dart';
|
||||
|
||||
/// SMS에서 서비스 정보를 추출하는 서비스 클래스
|
||||
class SmsExtractorService {
|
||||
final UrlMatcherService _urlMatcher;
|
||||
final CategoryMapperService _categoryMapper;
|
||||
|
||||
SmsExtractorService(this._urlMatcher, this._categoryMapper);
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||
// URL 패턴 찾기
|
||||
final urlPattern = RegExp(
|
||||
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final matches = urlPattern.allMatches(smsText);
|
||||
|
||||
for (final match in matches) {
|
||||
final url = match.group(0);
|
||||
if (url != null) {
|
||||
final serviceInfo = await _urlMatcher.findServiceByUrl(url);
|
||||
if (serviceInfo != null) {
|
||||
return serviceInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL로 못 찾았으면 서비스명으로 시도
|
||||
final lowerSms = smsText.toLowerCase();
|
||||
|
||||
// 모든 서비스명 검사
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerSms.contains(entry.key.toLowerCase())) {
|
||||
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other';
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: entry.value,
|
||||
cancellationUrl: null,
|
||||
categoryId: categoryId,
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
235
lib/services/url_matcher/services/url_matcher_service.dart
Normal file
235
lib/services/url_matcher/services/url_matcher_service.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import '../models/service_info.dart';
|
||||
import '../data/service_data_repository.dart';
|
||||
import '../data/legacy_service_data.dart';
|
||||
import 'category_mapper_service.dart';
|
||||
|
||||
/// URL 매칭 관련 기능을 제공하는 서비스 클래스
|
||||
class UrlMatcherService {
|
||||
final ServiceDataRepository _dataRepository;
|
||||
final CategoryMapperService _categoryMapper;
|
||||
|
||||
UrlMatcherService(this._dataRepository, this._categoryMapper);
|
||||
|
||||
/// 도메인 추출 (www와 TLD 제외)
|
||||
String? extractDomain(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final host = uri.host.toLowerCase();
|
||||
|
||||
// 도메인 부분 추출
|
||||
var parts = host.split('.');
|
||||
|
||||
// www 제거
|
||||
if (parts.isNotEmpty && parts[0] == 'www') {
|
||||
parts = parts.sublist(1);
|
||||
}
|
||||
|
||||
// 서브도메인 처리 (예: music.youtube.com)
|
||||
if (parts.length >= 3) {
|
||||
// 서브도메인 포함 전체 도메인 반환
|
||||
return parts.sublist(0, parts.length - 1).join('.');
|
||||
} else if (parts.length >= 2) {
|
||||
// 메인 도메인만 반환
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('UrlMatcherService: 도메인 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// URL로 서비스 찾기
|
||||
Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||
final domain = extractDomain(url);
|
||||
if (domain == null) return null;
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
final servicesData = _dataRepository.getServicesData();
|
||||
if (servicesData != null) {
|
||||
final categories = servicesData['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceEntry in services.entries) {
|
||||
final serviceId = serviceEntry.key;
|
||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
||||
|
||||
// 도메인이 일치하는지 확인
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final names = List<String>.from(serviceData['names'] ?? []);
|
||||
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: serviceId,
|
||||
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
||||
serviceUrl: urls?['kr'] ?? urls?['en'],
|
||||
cancellationUrl: null,
|
||||
categoryId: _categoryMapper.getCategoryIdByKey(categoryId),
|
||||
categoryNameKr: categoryData['nameKr'] ?? '',
|
||||
categoryNameEn: categoryData['nameEn'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final serviceUrl = entry.value;
|
||||
final serviceDomain = extractDomain(serviceUrl);
|
||||
|
||||
if (serviceDomain != null &&
|
||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: serviceUrl,
|
||||
cancellationUrl: null,
|
||||
categoryId: _categoryMapper.getCategoryForLegacyService(entry.key),
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스명으로 URL 찾기
|
||||
String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('UrlMatcherService: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 비교
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// OTT 서비스 검사
|
||||
for (final entry in LegacyServiceData.ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 음악 서비스 검사
|
||||
for (final entry in LegacyServiceData.musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 서비스 검사
|
||||
for (final entry in LegacyServiceData.aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 프로그래밍 서비스 검사
|
||||
for (final entry in LegacyServiceData.programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 오피스 툴 검사
|
||||
for (final entry in LegacyServiceData.officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 서비스 검사
|
||||
for (final entry in LegacyServiceData.otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 서비스에서 부분 매칭 재시도
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final key = entry.key.toLowerCase();
|
||||
if (key.contains(lowerName) || lowerName.contains(key)) {
|
||||
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
print('UrlMatcherService: 매칭 실패 - $lowerName');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('UrlMatcherService: suggestUrl 에러 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// URL이 알려진 서비스 URL인지 확인
|
||||
Future<bool> isKnownServiceUrl(String url) async {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
return serviceInfo != null;
|
||||
}
|
||||
|
||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환
|
||||
String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
// 입력 텍스트가 비어있거나 null인 경우
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 정확히 일치하는 경우
|
||||
if (LegacyServiceData.allServices.containsKey(lowerText)) {
|
||||
return LegacyServiceData.allServices[lowerText];
|
||||
}
|
||||
|
||||
// 부분 일치 검색이 활성화된 경우
|
||||
if (usePartialMatch) {
|
||||
// 가장 긴 부분 매칭 찾기
|
||||
String? bestMatch;
|
||||
int maxLength = 0;
|
||||
|
||||
for (var entry in LegacyServiceData.allServices.entries) {
|
||||
final String key = entry.key;
|
||||
|
||||
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
// 더 긴 매칭을 우선시
|
||||
if (key.length > maxLength) {
|
||||
maxLength = key.length;
|
||||
bestMatch = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user