feat: 구독 URL 매칭 서비스 개선 및 컨트롤러 최적화
- URL 매칭 로직 개선 - 구독 추가/상세 화면 컨트롤러 리팩토링 - assets 폴더 구조 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
736
assets/data/subscription_services.json
Normal file
736
assets/data/subscription_services.json
Normal file
@@ -0,0 +1,736 @@
|
||||
{
|
||||
"categories": {
|
||||
"music": {
|
||||
"nameKr": "음악 스트리밍",
|
||||
"nameEn": "Music Streaming",
|
||||
"services": {
|
||||
"spotify": {
|
||||
"names": ["spotify", "스포티파이"],
|
||||
"urls": {
|
||||
"kr": "https://www.spotify.com/kr/",
|
||||
"en": "https://www.spotify.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.spotify.com/kr/article/premium-구독-취소/",
|
||||
"en": "https://support.spotify.com/us/article/cancel-premium-subscription/"
|
||||
},
|
||||
"domains": ["spotify"]
|
||||
},
|
||||
"apple_music": {
|
||||
"names": ["apple music", "애플 뮤직", "애플뮤직"],
|
||||
"urls": {
|
||||
"kr": "https://www.apple.com/kr/apple-music/",
|
||||
"en": "https://music.apple.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.apple.com/ko-kr/HT204939",
|
||||
"en": "https://support.apple.com/en-us/HT204939"
|
||||
},
|
||||
"domains": ["apple", "music.apple"]
|
||||
},
|
||||
"youtube_music": {
|
||||
"names": ["youtube music", "유튜브 뮤직", "유튜브뮤직"],
|
||||
"urls": {
|
||||
"kr": "https://music.youtube.com",
|
||||
"en": "https://music.youtube.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.google.com/youtubemusic/answer/6313533?hl=ko",
|
||||
"en": "https://support.google.com/youtubemusic/answer/6313533?hl=en"
|
||||
},
|
||||
"domains": ["youtube", "music.youtube"]
|
||||
},
|
||||
"melon": {
|
||||
"names": ["melon", "멜론"],
|
||||
"urls": {
|
||||
"kr": "https://www.melon.com",
|
||||
"en": "https://www.melon.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.melon.com/customer/faq/faq_view.htm?faqSeq=3701",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["melon"]
|
||||
},
|
||||
"genie": {
|
||||
"names": ["genie", "지니", "genie music"],
|
||||
"urls": {
|
||||
"kr": "https://www.genie.co.kr",
|
||||
"en": "https://www.genie.co.kr"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.genie.co.kr/customer/faq/faq_view.htm?faqSeq=1132",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["genie"]
|
||||
},
|
||||
"flo": {
|
||||
"names": ["flo", "플로"],
|
||||
"urls": {
|
||||
"kr": "https://www.music-flo.com",
|
||||
"en": "https://www.music-flo.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["music-flo", "flo"]
|
||||
},
|
||||
"bugs": {
|
||||
"names": ["bugs", "벅스"],
|
||||
"urls": {
|
||||
"kr": "https://music.bugs.co.kr",
|
||||
"en": "https://music.bugs.co.kr"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.bugs.co.kr/faq/faqDetail?faqId=1000000000000039",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["bugs"]
|
||||
},
|
||||
"vibe": {
|
||||
"names": ["vibe", "바이브"],
|
||||
"urls": {
|
||||
"kr": "https://vibe.naver.com",
|
||||
"en": "https://vibe.naver.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["vibe"]
|
||||
},
|
||||
"tidal": {
|
||||
"names": ["tidal", "타이달"],
|
||||
"urls": {
|
||||
"kr": "https://tidal.com/kr/",
|
||||
"en": "https://tidal.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.tidal.com/hc/ko/articles/115003662529",
|
||||
"en": "https://support.tidal.com/hc/en-us/articles/115003662529-How-do-I-cancel-my-TIDAL-subscription-"
|
||||
},
|
||||
"domains": ["tidal"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ott": {
|
||||
"nameKr": "OTT 서비스",
|
||||
"nameEn": "OTT Services",
|
||||
"services": {
|
||||
"netflix": {
|
||||
"names": ["netflix", "넷플릭스"],
|
||||
"urls": {
|
||||
"kr": "https://www.netflix.com/kr/",
|
||||
"en": "https://www.netflix.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.netflix.com/ko/node/407",
|
||||
"en": "https://help.netflix.com/en/node/407"
|
||||
},
|
||||
"domains": ["netflix"]
|
||||
},
|
||||
"disney_plus": {
|
||||
"names": ["disney+", "디즈니플러스", "disney plus"],
|
||||
"urls": {
|
||||
"kr": "https://www.disneyplus.com/kr",
|
||||
"en": "https://www.disneyplus.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979",
|
||||
"en": "https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979"
|
||||
},
|
||||
"domains": ["disneyplus"]
|
||||
},
|
||||
"apple_tv": {
|
||||
"names": ["apple tv+", "애플 티비플러스", "애플티비"],
|
||||
"urls": {
|
||||
"kr": "https://tv.apple.com/kr/",
|
||||
"en": "https://tv.apple.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.apple.com/ko-kr/HT207043",
|
||||
"en": "https://support.apple.com/en-us/HT207043"
|
||||
},
|
||||
"domains": ["tv.apple"]
|
||||
},
|
||||
"youtube_premium": {
|
||||
"names": ["youtube premium", "유튜브 프리미엄"],
|
||||
"urls": {
|
||||
"kr": "https://www.youtube.com/premium?gl=KR",
|
||||
"en": "https://www.youtube.com/premium"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.google.com/youtube/answer/6306271?hl=ko",
|
||||
"en": "https://support.google.com/youtube/answer/6306271?hl=en"
|
||||
},
|
||||
"domains": ["youtube"]
|
||||
},
|
||||
"tving": {
|
||||
"names": ["tving", "티빙"],
|
||||
"urls": {
|
||||
"kr": "https://www.tving.com",
|
||||
"en": "https://www.tving.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.tving.com/my/cancelMembership",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["tving"]
|
||||
},
|
||||
"wavve": {
|
||||
"names": ["wavve", "웨이브"],
|
||||
"urls": {
|
||||
"kr": "https://www.wavve.com",
|
||||
"en": "https://www.wavve.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.wavve.com/my",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["wavve"]
|
||||
},
|
||||
"watcha": {
|
||||
"names": ["watcha", "왓챠"],
|
||||
"urls": {
|
||||
"kr": "https://watcha.com",
|
||||
"en": "https://watcha.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://watcha.com/settings/payment",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["watcha"]
|
||||
},
|
||||
"coupang_play": {
|
||||
"names": ["coupang play", "쿠팡 플레이", "쿠팡플레이"],
|
||||
"urls": {
|
||||
"kr": "https://www.coupangplay.com",
|
||||
"en": "https://www.coupangplay.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["coupangplay", "play.coupangplay"]
|
||||
},
|
||||
"amazon_prime": {
|
||||
"names": ["amazon prime", "아마존 프라임", "prime video"],
|
||||
"urls": {
|
||||
"kr": "https://www.primevideo.com",
|
||||
"en": "https://www.primevideo.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M",
|
||||
"en": "https://www.amazon.com/gp/primecentral/managemembership"
|
||||
},
|
||||
"domains": ["primevideo", "amazon"]
|
||||
},
|
||||
"hulu": {
|
||||
"names": ["hulu", "훌루"],
|
||||
"urls": {
|
||||
"kr": "https://www.hulu.com",
|
||||
"en": "https://www.hulu.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.hulu.com/hc/ko/articles/360001164823",
|
||||
"en": "https://help.hulu.com/s/article/how-do-i-cancel"
|
||||
},
|
||||
"domains": ["hulu"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"nameKr": "저장/클라우드",
|
||||
"nameEn": "Storage/Cloud",
|
||||
"services": {
|
||||
"google_drive": {
|
||||
"names": ["google drive", "구글 드라이브", "구글드라이브"],
|
||||
"urls": {
|
||||
"kr": "https://www.google.com/drive/",
|
||||
"en": "https://www.google.com/drive/"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.google.com/drive/answer/2375082?hl=ko",
|
||||
"en": "https://support.google.com/drive/answer/2375082?hl=en"
|
||||
},
|
||||
"domains": ["drive.google", "google"]
|
||||
},
|
||||
"dropbox": {
|
||||
"names": ["dropbox", "드롭박스"],
|
||||
"urls": {
|
||||
"kr": "https://www.dropbox.com",
|
||||
"en": "https://www.dropbox.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans",
|
||||
"en": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans"
|
||||
},
|
||||
"domains": ["dropbox"]
|
||||
},
|
||||
"onedrive": {
|
||||
"names": ["onedrive", "원드라이브", "microsoft onedrive"],
|
||||
"urls": {
|
||||
"kr": "https://www.onedrive.com",
|
||||
"en": "https://www.onedrive.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": "https://support.microsoft.com/en-us/office/cancel-your-microsoft-365-subscription-"
|
||||
},
|
||||
"domains": ["onedrive"]
|
||||
},
|
||||
"icloud": {
|
||||
"names": ["icloud", "아이클라우드"],
|
||||
"urls": {
|
||||
"kr": "https://www.icloud.com",
|
||||
"en": "https://www.icloud.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.apple.com/ko-kr/HT207594",
|
||||
"en": "https://support.apple.com/en-us/HT207594"
|
||||
},
|
||||
"domains": ["icloud"]
|
||||
},
|
||||
"google_one": {
|
||||
"names": ["google one", "구글 원"],
|
||||
"urls": {
|
||||
"kr": "https://one.google.com",
|
||||
"en": "https://one.google.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.google.com/googleone/answer/9140429",
|
||||
"en": "https://support.google.com/googleone/answer/9140429"
|
||||
},
|
||||
"domains": ["one.google"]
|
||||
},
|
||||
"naver_mybox": {
|
||||
"names": ["naver mybox", "네이버 마이박스", "마이박스"],
|
||||
"urls": {
|
||||
"kr": "https://mybox.naver.com",
|
||||
"en": null
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.naver.com/service/5638/contents/10041?osType=PC",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["mybox.naver"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"nameKr": "AI 서비스",
|
||||
"nameEn": "AI Services",
|
||||
"services": {
|
||||
"chatgpt": {
|
||||
"names": ["chatgpt", "챗GPT", "chatgpt plus"],
|
||||
"urls": {
|
||||
"kr": "https://chat.openai.com",
|
||||
"en": "https://chat.openai.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription",
|
||||
"en": "https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription"
|
||||
},
|
||||
"domains": ["chat.openai", "openai"]
|
||||
},
|
||||
"claude": {
|
||||
"names": ["claude", "클로드", "claude pro"],
|
||||
"urls": {
|
||||
"kr": "https://claude.ai",
|
||||
"en": "https://claude.ai"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription",
|
||||
"en": "https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription"
|
||||
},
|
||||
"domains": ["claude"]
|
||||
},
|
||||
"midjourney": {
|
||||
"names": ["midjourney", "미드저니"],
|
||||
"urls": {
|
||||
"kr": "https://www.midjourney.com",
|
||||
"en": "https://www.midjourney.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://docs.midjourney.com/docs/manage-subscription",
|
||||
"en": "https://docs.midjourney.com/docs/manage-subscription"
|
||||
},
|
||||
"domains": ["midjourney"]
|
||||
},
|
||||
"perplexity": {
|
||||
"names": ["perplexity", "퍼플렉시티", "perplexity pro"],
|
||||
"urls": {
|
||||
"kr": "https://www.perplexity.ai",
|
||||
"en": "https://www.perplexity.ai"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["perplexity"]
|
||||
},
|
||||
"copilot": {
|
||||
"names": ["copilot", "코파일럿", "github copilot"],
|
||||
"urls": {
|
||||
"kr": "https://copilot.microsoft.com",
|
||||
"en": "https://copilot.microsoft.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["copilot.microsoft"]
|
||||
},
|
||||
"gemini": {
|
||||
"names": ["gemini", "제미니", "google gemini"],
|
||||
"urls": {
|
||||
"kr": "https://gemini.google.com",
|
||||
"en": "https://gemini.google.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["gemini.google"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"programming": {
|
||||
"nameKr": "프로그래밍/개발",
|
||||
"nameEn": "Programming/Development",
|
||||
"services": {
|
||||
"github": {
|
||||
"names": ["github", "깃허브"],
|
||||
"urls": {
|
||||
"kr": "https://github.com",
|
||||
"en": "https://github.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription",
|
||||
"en": "https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription"
|
||||
},
|
||||
"domains": ["github"]
|
||||
},
|
||||
"cursor": {
|
||||
"names": ["cursor", "커서"],
|
||||
"urls": {
|
||||
"kr": "https://cursor.com",
|
||||
"en": "https://cursor.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["cursor"]
|
||||
},
|
||||
"jetbrains": {
|
||||
"names": ["jetbrains", "제트브레인스", "intellij"],
|
||||
"urls": {
|
||||
"kr": "https://www.jetbrains.com",
|
||||
"en": "https://www.jetbrains.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-",
|
||||
"en": "https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-"
|
||||
},
|
||||
"domains": ["jetbrains"]
|
||||
},
|
||||
"aws": {
|
||||
"names": ["aws", "아마존 웹서비스", "amazon web services"],
|
||||
"urls": {
|
||||
"kr": "https://aws.amazon.com",
|
||||
"en": "https://aws.amazon.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["aws.amazon", "aws"]
|
||||
},
|
||||
"azure": {
|
||||
"names": ["azure", "애저", "microsoft azure"],
|
||||
"urls": {
|
||||
"kr": "https://azure.microsoft.com",
|
||||
"en": "https://azure.microsoft.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["azure.microsoft"]
|
||||
},
|
||||
"google_cloud": {
|
||||
"names": ["google cloud", "구글 클라우드", "gcp"],
|
||||
"urls": {
|
||||
"kr": "https://cloud.google.com",
|
||||
"en": "https://cloud.google.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["cloud.google"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"office": {
|
||||
"nameKr": "오피스/협업툴",
|
||||
"nameEn": "Office/Collaboration",
|
||||
"services": {
|
||||
"microsoft_365": {
|
||||
"names": ["microsoft 365", "마이크로소프트 365", "office 365", "오피스 365"],
|
||||
"urls": {
|
||||
"kr": "https://www.microsoft.com/microsoft-365",
|
||||
"en": "https://www.microsoft.com/microsoft-365"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b",
|
||||
"en": "https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b"
|
||||
},
|
||||
"domains": ["microsoft"]
|
||||
},
|
||||
"google_workspace": {
|
||||
"names": ["google workspace", "구글 워크스페이스"],
|
||||
"urls": {
|
||||
"kr": "https://workspace.google.com",
|
||||
"en": "https://workspace.google.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": "https://support.google.com/a/answer/1257646?hl=en"
|
||||
},
|
||||
"domains": ["workspace.google"]
|
||||
},
|
||||
"slack": {
|
||||
"names": ["slack", "슬랙"],
|
||||
"urls": {
|
||||
"kr": "https://slack.com",
|
||||
"en": "https://slack.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription",
|
||||
"en": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription"
|
||||
},
|
||||
"domains": ["slack"]
|
||||
},
|
||||
"notion": {
|
||||
"names": ["notion", "노션"],
|
||||
"urls": {
|
||||
"kr": "https://www.notion.so",
|
||||
"en": "https://www.notion.so"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription",
|
||||
"en": "https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription"
|
||||
},
|
||||
"domains": ["notion"]
|
||||
},
|
||||
"figma": {
|
||||
"names": ["figma", "피그마"],
|
||||
"urls": {
|
||||
"kr": "https://www.figma.com",
|
||||
"en": "https://www.figma.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["figma"]
|
||||
},
|
||||
"adobe_creative_cloud": {
|
||||
"names": ["adobe creative cloud", "어도비 크리에이티브 클라우드", "adobe cc"],
|
||||
"urls": {
|
||||
"kr": "https://www.adobe.com/creativecloud.html",
|
||||
"en": "https://www.adobe.com/creativecloud.html"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html",
|
||||
"en": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html"
|
||||
},
|
||||
"domains": ["adobe"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"lifestyle": {
|
||||
"nameKr": "생활/라이프스타일",
|
||||
"nameEn": "Lifestyle",
|
||||
"services": {
|
||||
"naver_plus": {
|
||||
"names": ["네이버 플러스", "naver plus"],
|
||||
"urls": {
|
||||
"kr": "https://plus.naver.com",
|
||||
"en": null
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.naver.com/service/5638/contents/10041?osType=PC",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["plus.naver"]
|
||||
},
|
||||
"kakao_subscribe": {
|
||||
"names": ["카카오 구독", "kakao subscribe"],
|
||||
"urls": {
|
||||
"kr": "https://subscribe.kakao.com",
|
||||
"en": null
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["subscribe.kakao"]
|
||||
},
|
||||
"coupang_wow": {
|
||||
"names": ["쿠팡 와우", "coupang wow", "쿠팡와우"],
|
||||
"urls": {
|
||||
"kr": "https://www.coupang.com/np/coupangplus",
|
||||
"en": null
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://help.coupang.com/cc/ko/contents/faq/1000002013",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["coupang"]
|
||||
},
|
||||
"kurly": {
|
||||
"names": ["마켓컬리", "kurly", "컬리"],
|
||||
"urls": {
|
||||
"kr": "https://www.kurly.com",
|
||||
"en": null
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["kurly"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"shopping": {
|
||||
"nameKr": "쇼핑/이커머스",
|
||||
"nameEn": "Shopping/E-commerce",
|
||||
"services": {
|
||||
"amazon_prime": {
|
||||
"names": ["amazon prime", "아마존 프라임"],
|
||||
"urls": {
|
||||
"kr": "https://www.amazon.co.kr/prime",
|
||||
"en": "https://www.amazon.com/prime"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M",
|
||||
"en": "https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M"
|
||||
},
|
||||
"domains": ["amazon"]
|
||||
},
|
||||
"walmart_plus": {
|
||||
"names": ["walmart+", "월마트플러스"],
|
||||
"urls": {
|
||||
"kr": null,
|
||||
"en": "https://www.walmart.com/plus"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": "https://www.walmart.com/help/article/how-do-i-cancel-my-walmart-membership/2c1f2b2c9e6e4e3c9c8d9e5e"
|
||||
},
|
||||
"domains": ["walmart"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gaming": {
|
||||
"nameKr": "게임",
|
||||
"nameEn": "Gaming",
|
||||
"services": {
|
||||
"nintendo_switch_online": {
|
||||
"names": ["nintendo switch online", "닌텐도 스위치 온라인"],
|
||||
"urls": {
|
||||
"kr": "https://www.nintendo.com/switch/online-service",
|
||||
"en": "https://www.nintendo.com/switch/online-service"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership",
|
||||
"en": "https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership"
|
||||
},
|
||||
"domains": ["nintendo"]
|
||||
},
|
||||
"playstation_plus": {
|
||||
"names": ["playstation plus", "플레이스테이션 플러스", "ps plus"],
|
||||
"urls": {
|
||||
"kr": "https://www.playstation.com/ps-plus",
|
||||
"en": "https://www.playstation.com/ps-plus"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/",
|
||||
"en": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/"
|
||||
},
|
||||
"domains": ["playstation"]
|
||||
},
|
||||
"xbox_game_pass": {
|
||||
"names": ["xbox game pass", "엑스박스 게임 패스"],
|
||||
"urls": {
|
||||
"kr": "https://www.xbox.com/xbox-game-pass",
|
||||
"en": "https://www.xbox.com/xbox-game-pass"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel",
|
||||
"en": "https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel"
|
||||
},
|
||||
"domains": ["xbox"]
|
||||
},
|
||||
"steam": {
|
||||
"names": ["steam", "스팀"],
|
||||
"urls": {
|
||||
"kr": "https://store.steampowered.com",
|
||||
"en": "https://store.steampowered.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["steampowered", "steam"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"telecom": {
|
||||
"nameKr": "통신/인터넷/TV",
|
||||
"nameEn": "Telecom/Internet/TV",
|
||||
"services": {
|
||||
"skt": {
|
||||
"names": ["skt", "sk텔레콤"],
|
||||
"urls": {
|
||||
"kr": "https://www.sktelecom.com",
|
||||
"en": "https://www.sktelecom.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.sktelecom.com/support/cancel.do",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["sktelecom"]
|
||||
},
|
||||
"kt": {
|
||||
"names": ["kt"],
|
||||
"urls": {
|
||||
"kr": "https://www.kt.com",
|
||||
"en": "https://www.kt.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": null,
|
||||
"en": null
|
||||
},
|
||||
"domains": ["kt"]
|
||||
},
|
||||
"lguplus": {
|
||||
"names": ["lgu+", "lg유플러스", "lg u+"],
|
||||
"urls": {
|
||||
"kr": "https://www.lguplus.com",
|
||||
"en": "https://www.lguplus.com"
|
||||
},
|
||||
"cancellationUrls": {
|
||||
"kr": "https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720",
|
||||
"en": null
|
||||
},
|
||||
"domains": ["lguplus"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
@@ -73,6 +73,9 @@ class AddSubscriptionController {
|
||||
// 서비스명 컨트롤러에 리스너 추가
|
||||
serviceNameController.addListener(onServiceNameChanged);
|
||||
|
||||
// 웹사이트 URL 컨트롤러에 리스너 추가
|
||||
websiteUrlController.addListener(onWebsiteUrlChanged);
|
||||
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
animationController = AnimationController(
|
||||
vsync: vsync,
|
||||
@@ -133,6 +136,52 @@ class AddSubscriptionController {
|
||||
void onServiceNameChanged() {
|
||||
autoSelectCategory();
|
||||
}
|
||||
|
||||
/// 웹사이트 URL 변경시 호출
|
||||
void onWebsiteUrlChanged() async {
|
||||
final url = websiteUrlController.text.trim();
|
||||
|
||||
// URL이 비어있거나 너무 짧으면 무시
|
||||
if (url.isEmpty || url.length < 5) return;
|
||||
|
||||
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
|
||||
if (serviceNameController.text.isNotEmpty) return;
|
||||
|
||||
try {
|
||||
// URL로 서비스 정보 찾기
|
||||
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
|
||||
|
||||
if (serviceInfo != null && context.mounted) {
|
||||
// 서비스명 자동 입력
|
||||
serviceNameController.text = serviceInfo.serviceName;
|
||||
|
||||
// 카테고리 자동 선택
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categories = categoryProvider.categories;
|
||||
|
||||
// 카테고리 ID로 매칭
|
||||
final matchedCategory = categories.firstWhere(
|
||||
(cat) => cat.name == serviceInfo.categoryNameKr ||
|
||||
cat.name == serviceInfo.categoryNameEn,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
|
||||
selectedCategoryId = matchedCategory.id;
|
||||
|
||||
// 스낵바로 알림
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showSuccess(
|
||||
context: context,
|
||||
message: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 카테고리 자동 선택
|
||||
void autoSelectCategory() {
|
||||
@@ -254,8 +303,42 @@ class AddSubscriptionController {
|
||||
}
|
||||
|
||||
final subscription = subscriptions.first;
|
||||
|
||||
// SMS에서 서비스 정보 추출 시도
|
||||
ServiceInfo? serviceInfo;
|
||||
final smsContent = subscription['smsContent'] ?? '';
|
||||
|
||||
if (smsContent.isNotEmpty) {
|
||||
try {
|
||||
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
serviceNameController.text = subscription['serviceName'] ?? '';
|
||||
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
|
||||
if (serviceInfo != null) {
|
||||
serviceNameController.text = serviceInfo.serviceName;
|
||||
websiteUrlController.text = serviceInfo.serviceUrl ?? '';
|
||||
|
||||
// 카테고리 자동 선택
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categories = categoryProvider.categories;
|
||||
|
||||
final matchedCategory = categories.firstWhere(
|
||||
(cat) => cat.name == serviceInfo!.categoryNameKr ||
|
||||
cat.name == serviceInfo.categoryNameEn,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
|
||||
selectedCategoryId = matchedCategory.id;
|
||||
} else {
|
||||
// 기존 로직 사용
|
||||
serviceNameController.text = subscription['serviceName'] ?? '';
|
||||
}
|
||||
|
||||
// 비용 처리 및 통화 단위 자동 감지
|
||||
final costValue = subscription['monthlyCost']?.toString() ?? '';
|
||||
@@ -289,12 +372,13 @@ class AddSubscriptionController {
|
||||
? DateTime.parse(subscription['nextBillingDate'])
|
||||
: DateTime.now();
|
||||
|
||||
// 서비스명이 있으면 URL 자동 매칭 시도
|
||||
if (subscription['serviceName'] != null &&
|
||||
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
|
||||
if (serviceInfo == null &&
|
||||
subscription['serviceName'] != null &&
|
||||
subscription['serviceName'].isNotEmpty) {
|
||||
final suggestedUrl =
|
||||
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
|
||||
if (suggestedUrl != null) {
|
||||
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
|
||||
websiteUrlController.text = suggestedUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../models/category_model.dart';
|
||||
@@ -439,9 +440,32 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
/// 해지 페이지 열기
|
||||
Future<void> openCancellationPage() async {
|
||||
if (subscription.websiteUrl != null &&
|
||||
subscription.websiteUrl!.isNotEmpty) {
|
||||
final Uri url = Uri.parse(subscription.websiteUrl!);
|
||||
try {
|
||||
// 1. 현재 언어 설정 가져오기
|
||||
final locale = Localizations.localeOf(context).languageCode;
|
||||
|
||||
// 2. 해지 안내 URL 찾기
|
||||
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl(
|
||||
serviceName: subscription.serviceName,
|
||||
websiteUrl: subscription.websiteUrl,
|
||||
locale: locale == 'ko' ? 'kr' : 'en',
|
||||
);
|
||||
|
||||
// 3. 해지 안내 URL이 없으면 구글 검색
|
||||
if (cancellationUrl == null) {
|
||||
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
||||
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. URL 열기
|
||||
final Uri url = Uri.parse(cancellationUrl);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
@@ -450,12 +474,30 @@ class DetailScreenController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showWarning(
|
||||
context: context,
|
||||
message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
||||
}
|
||||
|
||||
// 오류 발생시 일반 웹사이트로 폴백
|
||||
if (subscription.websiteUrl != null &&
|
||||
subscription.websiteUrl!.isNotEmpty) {
|
||||
final Uri url = Uri.parse(subscription.websiteUrl!);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: '웹사이트를 열 수 없습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showWarning(
|
||||
context: context,
|
||||
message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,6 @@ dev_dependencies:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/data/
|
||||
|
||||
Reference in New Issue
Block a user