feat: 구독 URL 매칭 서비스 개선 및 컨트롤러 최적화

- URL 매칭 로직 개선
- 구독 추가/상세 화면 컨트롤러 리팩토링
- assets 폴더 구조 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-14 19:13:13 +09:00
parent ddf735149a
commit 917a68aa14
5 changed files with 1260 additions and 66 deletions

View File

@@ -1,6 +1,34 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
/// 서비스 정보를 담는 데이터 클래스
class ServiceInfo {
final String serviceId;
final String serviceName;
final String? serviceUrl;
final String? cancellationUrl;
final String categoryId;
final String categoryNameKr;
final String categoryNameEn;
ServiceInfo({
required this.serviceId,
required this.serviceName,
this.serviceUrl,
this.cancellationUrl,
required this.categoryId,
required this.categoryNameKr,
required this.categoryNameEn,
});
}
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
class SubscriptionUrlMatcher {
static Map<String, dynamic>? _servicesData;
static bool _isInitialized = false;
// 레거시 데이터 (JSON 로드 실패시 폴백)
// OTT 서비스
static final Map<String, String> ottServices = {
'netflix': 'https://www.netflix.com',
@@ -339,58 +367,127 @@ class SubscriptionUrlMatcher {
static final Map<String, String> allServices = {
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환
///
/// [text] 검색할 텍스트 (서비스명)
/// [usePartialMatch] 부분 일치도 허용할지 여부 (기본값: true)
///
/// 반환값: 매칭된 URL 또는 null (매칭 실패시)
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
// 입력 텍스트가 비어있거나 null인 경우
if (text.isEmpty) {
/// JSON 데이터 초기화
static 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('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
} catch (e) {
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true;
}
}
/// 도메인 추출 (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;
}
// 소문자로 변환하여 처리
final String lowerText = text.toLowerCase().trim();
// 정확히 일치하는 경우
if (allServices.containsKey(lowerText)) {
return allServices[lowerText];
}
// 부분 일치 검색이 활성화된 경우
if (usePartialMatch) {
// 가장 긴 부분 매칭 찾기
String? bestMatch;
int maxLength = 0;
for (var entry in allServices.entries) {
final String key = entry.key;
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
if (lowerText.contains(key) || key.contains(lowerText)) {
// 더 긴 매칭을 우선시
if (key.length > maxLength) {
maxLength = key.length;
bestMatch = entry.value;
}
/// 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'] ?? '',
);
}
}
}
}
return bestMatch;
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in 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;
}
/// 서비스 이름을 기반으로 URL 제안
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
static String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) {
print('SubscriptionUrlMatcher: 빈 serviceName');
@@ -498,13 +595,74 @@ class SubscriptionUrlMatcher {
return null;
}
}
/// 해지 안내 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 ?? '');
}
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기
///
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
///
/// 반환값: 해지 안내 페이지 URL 또는 null (해지 안내 페이지가 없는 경우)
static String? findCancellationUrl(String serviceNameOrUrl) {
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
static String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
if (serviceNameOrUrl.isEmpty) {
return null;
}
@@ -548,11 +706,182 @@ class SubscriptionUrlMatcher {
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
///
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
///
/// 반환값: 해지 안내 페이지 제공 여부
static bool hasCancellationPage(String serviceNameOrUrl) {
return findCancellationUrl(serviceNameOrUrl) != null;
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인
final cancellationUrl = await findCancellationUrl(
serviceName: serviceNameOrUrl,
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
}
}
/// 서비스명으로 카테고리 찾기
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);
}
/// 카테고리 키를 실제 카테고리 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 (ottServices.containsKey(lowerName)) return 'ott_services';
if (musicServices.containsKey(lowerName)) return 'music_streaming';
if (storageServices.containsKey(lowerName)) return 'cloud_storage';
if (aiServices.containsKey(lowerName)) return 'ai_services';
if (programmingServices.containsKey(lowerName)) return 'dev_tools';
if (officeTools.containsKey(lowerName)) return 'office_tools';
if (lifestyleServices.containsKey(lowerName)) return 'lifestyle';
if (shoppingServices.containsKey(lowerName)) return 'shopping';
if (telecomServices.containsKey(lowerName)) return 'telecom';
return 'other';
}
/// 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 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;
}
/// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
return serviceInfo != null;
}
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
// 입력 텍스트가 비어있거나 null인 경우
if (text.isEmpty) {
return null;
}
// 소문자로 변환하여 처리
final String lowerText = text.toLowerCase().trim();
// 정확히 일치하는 경우
if (allServices.containsKey(lowerText)) {
return allServices[lowerText];
}
// 부분 일치 검색이 활성화된 경우
if (usePartialMatch) {
// 가장 긴 부분 매칭 찾기
String? bestMatch;
int maxLength = 0;
for (var entry in 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;
}
}