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? _servicesData; static bool _isInitialized = false; // 레거시 데이터 (JSON 로드 실패시 폴백) // OTT 서비스 static final Map ottServices = { 'netflix': 'https://www.netflix.com', '넷플릭스': 'https://www.netflix.com', 'disney+': 'https://www.disneyplus.com', '디즈니플러스': 'https://www.disneyplus.com', 'youtube premium': 'https://www.youtube.com/premium', '유튜브 프리미엄': 'https://www.youtube.com/premium', 'watcha': 'https://watcha.com', '왓챠': 'https://watcha.com', 'wavve': 'https://www.wavve.com', '웨이브': 'https://www.wavve.com', 'apple tv+': 'https://tv.apple.com', '애플 티비플러스': 'https://tv.apple.com', 'tving': 'https://www.tving.com', '티빙': 'https://www.tving.com', 'prime video': 'https://www.primevideo.com', '프라임 비디오': 'https://www.primevideo.com', 'amazon prime': 'https://www.amazon.com/prime', '아마존 프라임': 'https://www.amazon.com/prime', 'coupang play': 'https://play.coupangplay.com', '쿠팡 플레이': 'https://play.coupangplay.com', 'hulu': 'https://www.hulu.com', '훌루': 'https://www.hulu.com', }; // 음악 서비스 static final Map musicServices = { 'spotify': 'https://www.spotify.com', '스포티파이': 'https://www.spotify.com', 'apple music': 'https://music.apple.com', '애플 뮤직': 'https://music.apple.com', 'melon': 'https://www.melon.com', '멜론': 'https://www.melon.com', 'genie': 'https://www.genie.co.kr', '지니': 'https://www.genie.co.kr', 'youtube music': 'https://music.youtube.com', '유튜브 뮤직': 'https://music.youtube.com', 'bugs': 'https://music.bugs.co.kr', '벅스': 'https://music.bugs.co.kr', 'flo': 'https://www.music-flo.com', '플로': 'https://www.music-flo.com', 'vibe': 'https://vibe.naver.com', '바이브': 'https://vibe.naver.com', 'tidal': 'https://www.tidal.com', '타이달': 'https://www.tidal.com', }; // 저장 (클라우드/파일) 서비스 static final Map storageServices = { 'google drive': 'https://www.google.com/drive/', '구글 드라이브': 'https://www.google.com/drive/', 'dropbox': 'https://www.dropbox.com', '드롭박스': 'https://www.dropbox.com', 'onedrive': 'https://www.onedrive.com', '원드라이브': 'https://www.onedrive.com', 'icloud': 'https://www.icloud.com', '아이클라우드': 'https://www.icloud.com', 'box': 'https://www.box.com', '박스': 'https://www.box.com', 'pcloud': 'https://www.pcloud.com', 'mega': 'https://mega.nz', '메가': 'https://mega.nz', 'naver mybox': 'https://mybox.naver.com', '네이버 마이박스': 'https://mybox.naver.com', }; // 통신 · 인터넷 · TV 서비스 static final Map telecomServices = { 'skt': 'https://www.sktelecom.com', 'sk텔레콤': 'https://www.sktelecom.com', 'kt': 'https://www.kt.com', 'lgu+': 'https://www.lguplus.com', 'lg유플러스': 'https://www.lguplus.com', 'olleh tv': 'https://www.kt.com/olleh_tv', '올레 tv': 'https://www.kt.com/olleh_tv', 'b tv': 'https://www.skbroadband.com', '비티비': 'https://www.skbroadband.com', 'u+모바일tv': 'https://www.lguplus.com', '유플러스모바일tv': 'https://www.lguplus.com', }; // 생활/라이프스타일 서비스 static final Map lifestyleServices = { '네이버 플러스': 'https://plus.naver.com', 'naver plus': 'https://plus.naver.com', '카카오 구독': 'https://subscribe.kakao.com', 'kakao subscribe': 'https://subscribe.kakao.com', '쿠팡 와우': 'https://www.coupang.com/np/coupangplus', 'coupang wow': 'https://www.coupang.com/np/coupangplus', '스타벅스 버디': 'https://www.starbucks.co.kr', 'starbucks buddy': 'https://www.starbucks.co.kr', 'cu 구독': 'https://cu.bgfretail.com', 'gs25 구독': 'https://gs25.gsretail.com', '현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription', 'lg전자 구독': 'https://www.lge.co.kr', '삼성전자 구독': 'https://www.samsung.com/sec', '다이슨 케어': 'https://www.dyson.co.kr', 'dyson care': 'https://www.dyson.co.kr', '마켓컬리': 'https://www.kurly.com', 'kurly': 'https://www.kurly.com', '헬로네이처': 'https://www.hellonature.com', 'hello nature': 'https://www.hellonature.com', '이마트 트레이더스': 'https://www.emarttraders.co.kr', '홈플러스': 'https://www.homeplus.co.kr', 'hellofresh': 'https://www.hellofresh.com', '헬로프레시': 'https://www.hellofresh.com', 'bespoke post': 'https://www.bespokepost.com', }; // 쇼핑/이커머스 서비스 static final Map shoppingServices = { 'amazon prime': 'https://www.amazon.com/prime', '아마존 프라임': 'https://www.amazon.com/prime', 'walmart+': 'https://www.walmart.com/plus', '월마트플러스': 'https://www.walmart.com/plus', 'chewy': 'https://www.chewy.com', '츄이': 'https://www.chewy.com', 'dollar shave club': 'https://www.dollarshaveclub.com', '달러셰이브클럽': 'https://www.dollarshaveclub.com', 'instacart': 'https://www.instacart.com', '인스타카트': 'https://www.instacart.com', 'shipt': 'https://www.shipt.com', '십트': 'https://www.shipt.com', 'grove': 'https://grove.co', '그로브': 'https://grove.co', 'cratejoy': 'https://www.cratejoy.com', 'shopify': 'https://www.shopify.com', '쇼피파이': 'https://www.shopify.com', }; // AI 서비스 static final Map aiServices = { 'chatgpt': 'https://chat.openai.com', '챗GPT': 'https://chat.openai.com', 'openai': 'https://openai.com', '오픈AI': 'https://openai.com', 'claude': 'https://claude.ai', '클로드': 'https://claude.ai', 'anthropic': 'https://www.anthropic.com', '앤트로픽': 'https://www.anthropic.com', 'midjourney': 'https://www.midjourney.com', '미드저니': 'https://www.midjourney.com', 'perplexity': 'https://www.perplexity.ai', '퍼플렉시티': 'https://www.perplexity.ai', 'copilot': 'https://copilot.microsoft.com', '코파일럿': 'https://copilot.microsoft.com', 'gemini': 'https://gemini.google.com', '제미니': 'https://gemini.google.com', 'google ai': 'https://ai.google', '구글 AI': 'https://ai.google', 'bard': 'https://bard.google.com', '바드': 'https://bard.google.com', 'dall-e': 'https://openai.com/dall-e', '달리': 'https://openai.com/dall-e', 'stable diffusion': 'https://stability.ai', '스테이블 디퓨전': 'https://stability.ai', }; // 프로그래밍 / 개발 서비스 static final Map programmingServices = { 'github': 'https://github.com', '깃허브': 'https://github.com', 'cursor': 'https://cursor.com', '커서': 'https://cursor.com', 'jetbrains': 'https://www.jetbrains.com', '제트브레인스': 'https://www.jetbrains.com', 'intellij': 'https://www.jetbrains.com/idea', '인텔리제이': 'https://www.jetbrains.com/idea', 'visual studio': 'https://visualstudio.microsoft.com', '비주얼 스튜디오': 'https://visualstudio.microsoft.com', 'aws': 'https://aws.amazon.com', '아마존 웹서비스': 'https://aws.amazon.com', 'azure': 'https://azure.microsoft.com', '애저': 'https://azure.microsoft.com', 'google cloud': 'https://cloud.google.com', '구글 클라우드': 'https://cloud.google.com', 'digitalocean': 'https://www.digitalocean.com', '디지털오션': 'https://www.digitalocean.com', 'heroku': 'https://www.heroku.com', '헤로쿠': 'https://www.heroku.com', 'codecademy': 'https://www.codecademy.com', '코드아카데미': 'https://www.codecademy.com', 'udemy': 'https://www.udemy.com', '유데미': 'https://www.udemy.com', 'coursera': 'https://www.coursera.org', '코세라': 'https://www.coursera.org', }; // 오피스 및 협업 툴 static final Map officeTools = { 'microsoft 365': 'https://www.microsoft.com/microsoft-365', '마이크로소프트 365': 'https://www.microsoft.com/microsoft-365', 'office 365': 'https://www.microsoft.com/microsoft-365', '오피스 365': 'https://www.microsoft.com/microsoft-365', 'google workspace': 'https://workspace.google.com', '구글 워크스페이스': 'https://workspace.google.com', 'slack': 'https://slack.com', '슬랙': 'https://slack.com', 'notion': 'https://www.notion.so', '노션': 'https://www.notion.so', 'trello': 'https://trello.com', '트렐로': 'https://trello.com', 'asana': 'https://asana.com', '아사나': 'https://asana.com', 'dropbox': 'https://www.dropbox.com', '드롭박스': 'https://www.dropbox.com', 'figma': 'https://www.figma.com', '피그마': 'https://www.figma.com', 'adobe creative cloud': 'https://www.adobe.com/creativecloud.html', '어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html', }; // 기타 유명 서비스 static final Map otherServices = { 'google one': 'https://one.google.com', '구글 원': 'https://one.google.com', 'icloud': 'https://www.icloud.com', '아이클라우드': 'https://www.icloud.com', 'nintendo switch online': 'https://www.nintendo.com/switch/online-service', '닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service', 'playstation plus': 'https://www.playstation.com/ps-plus', '플레이스테이션 플러스': 'https://www.playstation.com/ps-plus', 'xbox game pass': 'https://www.xbox.com/xbox-game-pass', '엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass', 'ea play': 'https://www.ea.com/ea-play', 'EA 플레이': 'https://www.ea.com/ea-play', 'ubisoft+': 'https://ubisoft.com/plus', '유비소프트+': 'https://ubisoft.com/plus', 'epic games': 'https://www.epicgames.com', '에픽 게임즈': 'https://www.epicgames.com', 'steam': 'https://store.steampowered.com', '스팀': 'https://store.steampowered.com', }; // 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들) static final Map cancellationUrls = { // OTT 서비스 해지 안내 페이지 'netflix': 'https://help.netflix.com/ko/node/407', '넷플릭스': 'https://help.netflix.com/ko/node/407', 'disney+': 'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979', '디즈니플러스': 'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979', 'youtube premium': 'https://support.google.com/youtube/answer/6308278', '유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278', 'watcha': 'https://watcha.com/settings/payment', '왓챠': 'https://watcha.com/settings/payment', 'wavve': 'https://www.wavve.com/my', '웨이브': 'https://www.wavve.com/my', 'apple tv+': 'https://support.apple.com/ko-kr/HT202039', '애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039', 'tving': 'https://www.tving.com/my/cancelMembership', '티빙': 'https://www.tving.com/my/cancelMembership', 'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership', '아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership', // 음악 서비스 해지 안내 페이지 'spotify': 'https://support.spotify.com/us/article/cancel-premium/', '스포티파이': 'https://support.spotify.com/us/article/cancel-premium/', 'apple music': 'https://support.apple.com/ko-kr/HT202039', '애플 뮤직': 'https://support.apple.com/ko-kr/HT202039', 'melon': 'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021', '멜론': 'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021', 'youtube music': 'https://support.google.com/youtubemusic/answer/6308278', '유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278', // AI 서비스 해지 안내 페이지 'chatgpt': 'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription', '챗GPT': 'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription', 'claude': 'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription', '클로드': 'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription', 'midjourney': 'https://docs.midjourney.com/docs/manage-subscription', '미드저니': 'https://docs.midjourney.com/docs/manage-subscription', // 프로그래밍 / 개발 서비스 해지 안내 페이지 'github': 'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription', '깃허브': 'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription', 'jetbrains': 'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-', '제트브레인스': 'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-', // 오피스 및 협업 툴 해지 안내 페이지 'microsoft 365': 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', '마이크로소프트 365': 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', 'office 365': 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', '오피스 365': 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', 'slack': 'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription', '슬랙': 'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription', 'notion': 'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription', '노션': 'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription', 'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation', '드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation', 'adobe creative cloud': 'https://helpx.adobe.com/manage-account/using/cancel-subscription.html', '어도비 크리에이티브 클라우드': 'https://helpx.adobe.com/manage-account/using/cancel-subscription.html', // 기타 유명 서비스 해지 안내 페이지 'google one': 'https://support.google.com/googleone/answer/9140429', '구글 원': 'https://support.google.com/googleone/answer/9140429', 'icloud': 'https://support.apple.com/ko-kr/HT207594', '아이클라우드': 'https://support.apple.com/ko-kr/HT207594', 'nintendo switch online': 'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership', '닌텐도 스위치 온라인': 'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership', 'playstation plus': 'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/', '플레이스테이션 플러스': 'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/', 'xbox game pass': 'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel', '엑스박스 게임 패스': 'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel', }; // 모든 서비스 매핑을 합친 맵 static final Map allServices = { ...ottServices, ...musicServices, ...storageServices, ...aiServices, ...programmingServices, ...officeTools, ...lifestyleServices, ...shoppingServices, ...telecomServices, ...otherServices, }; /// JSON 데이터 초기화 static Future 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; } } /// URL로 서비스 찾기 static Future 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; for (final categoryEntry in categories.entries) { final categoryId = categoryEntry.key; final categoryData = categoryEntry.value as Map; final services = categoryData['services'] as Map; for (final serviceEntry in services.entries) { final serviceId = serviceEntry.key; final serviceData = serviceEntry.value as Map; final domains = List.from(serviceData['domains'] ?? []); // 도메인이 일치하는지 확인 for (final serviceDomain in domains) { if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { final names = List.from(serviceData['names'] ?? []); final urls = serviceData['urls'] as Map?; 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 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 찾기 (기존 suggestUrl 메서드 유지) static String? suggestUrl(String serviceName) { if (serviceName.isEmpty) { print('SubscriptionUrlMatcher: 빈 serviceName'); return null; } // 소문자로 변환하여 비교 final lowerName = serviceName.toLowerCase().trim(); try { // 정확한 매칭을 먼저 시도 for (final entry in allServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // OTT 서비스 검사 for (final entry in ottServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // 음악 서비스 검사 for (final entry in musicServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // AI 서비스 검사 for (final entry in aiServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // 개발 서비스 검사 for (final entry in programmingServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // 오피스 툴 검사 for (final entry in officeTools.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // 기타 서비스 검사 for (final entry in otherServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { print( 'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } // 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색 for (final entry in 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; } } /// 해지 안내 URL 찾기 (개선된 버전) static Future findCancellationUrl({ String? serviceName, String? websiteUrl, String locale = 'kr', }) async { await initialize(); // JSON 데이터가 있으면 JSON에서 찾기 if (_servicesData != null) { final categories = _servicesData!['categories'] as Map; // 1. 서비스명으로 찾기 if (serviceName != null && serviceName.isNotEmpty) { final lowerName = serviceName.toLowerCase().trim(); for (final categoryData in categories.values) { final services = (categoryData as Map)['services'] as Map; for (final serviceData in services.values) { final names = List.from((serviceData as Map)['names'] ?? []); for (final name in names) { if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { final cancellationUrls = serviceData['cancellationUrls'] as Map?; 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)['services'] as Map; for (final serviceData in services.values) { final domains = List.from((serviceData as Map)['domains'] ?? []); for (final serviceDomain in domains) { if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { final cancellationUrls = serviceData['cancellationUrls'] as Map?; if (cancellationUrls != null) { return cancellationUrls[locale] ?? cancellationUrls[locale == 'kr' ? 'en' : 'kr']; } } } } } } } } // JSON에서 못 찾았으면 레거시 방식으로 찾기 return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); } /// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시) static String? _findCancellationUrlLegacy(String serviceNameOrUrl) { if (serviceNameOrUrl.isEmpty) { return null; } // 소문자로 변환하여 처리 final String lowerText = serviceNameOrUrl.toLowerCase().trim(); // 직접 서비스명으로 찾기 if (cancellationUrls.containsKey(lowerText)) { return cancellationUrls[lowerText]; } // 서비스명에 부분 포함으로 찾기 for (var entry in 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 cancellationUrls.entries) { if (entry.key.toLowerCase().contains(domain)) { return entry.value; } } } } // 해지 안내 페이지를 찾지 못함 return null; } /// 서비스에 공식 해지 안내 페이지가 있는지 확인 static Future hasCancellationPage(String serviceNameOrUrl) async { // 새로운 JSON 기반 방식으로 확인 final cancellationUrl = await findCancellationUrl( serviceName: serviceNameOrUrl, websiteUrl: serviceNameOrUrl, ); return cancellationUrl != null; } /// 서비스명으로 카테고리 찾기 static Future 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; for (final categoryEntry in categories.entries) { final categoryId = categoryEntry.key; final categoryData = categoryEntry.value as Map; final services = categoryData['services'] as Map; for (final serviceData in services.values) { final names = List.from((serviceData as Map)['names'] ?? []); for (final name in names) { if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { return _getCategoryIdByKey(categoryId); } } } } } // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 return _getCategoryForLegacyService(serviceName); } /// 현재 로케일에 따라 서비스 표시명 가져오기 static Future getServiceDisplayName({ required String serviceName, required String locale, }) async { await initialize(); if (_servicesData == null) { return serviceName; } final lowerName = serviceName.toLowerCase().trim(); final categories = _servicesData!['categories'] as Map; // JSON에서 서비스 찾기 for (final categoryData in categories.values) { final services = (categoryData as Map)['services'] as Map; for (final serviceData in services.values) { final data = serviceData as Map; final names = List.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 (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 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 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; } }