using System.Text.RegularExpressions; namespace VideoDownloader.App.Detection; /// /// 소셜 영상 페이지(YouTube/Shorts/Instagram/X/TikTok/Facebook) 판별 + URL 정규화. /// yt-dlp 로 처리 가능한 단일 영상 URL 인지 검사하고, 같은 영상의 여러 URL 형태를 /// 하나의 정규화된 키로 통일(중복 등록 방지). 홈/검색/채널 페이지는 거짓. /// public static class SocialSiteDetector { private static readonly Regex YtId = new(@"^[A-Za-z0-9_-]{11}$", RegexOptions.Compiled); /// 소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환. public static bool TryMatch(Uri url, out string normalized, out string label) { normalized = ""; label = ""; var host = (url.Host ?? "").ToLowerInvariant(); var path = url.AbsolutePath ?? ""; // ===== YouTube / Shorts ===== if (host.EndsWith("youtube.com") || host == "m.youtube.com" || host == "youtu.be") { string? id = ExtractYouTubeId(host, url, path); if (id != null && YtId.IsMatch(id)) { normalized = $"https://www.youtube.com/watch?v={id}"; label = "YOUTUBE"; return true; } return false; } // ===== Instagram ===== if (host.EndsWith("instagram.com")) { // /reel// , /reels// , /p// , /tv// , /stories/// var m = Regex.Match(path, "/(?:reels?|p|tv)/([^/?#]+)"); if (!m.Success) m = Regex.Match(path, "/stories/([^/]+)/([^/?#]+)"); if (m.Success) { normalized = Normalize(host, m.Value); // 매칭된 경로만 보존 label = "INSTAGRAM"; return true; } return false; } // ===== X / Twitter ===== if (host == "x.com" || host == "twitter.com" || host.EndsWith(".x.com") || host.EndsWith(".twitter.com")) { var m = Regex.Match(path, "/[^/]+/status/([0-9]+)"); if (m.Success) { normalized = Normalize(host, m.Value); label = "X"; return true; } return false; } // ===== TikTok ===== if (host.EndsWith("tiktok.com")) { var m = Regex.Match(path, "/@[^/]+/video/([0-9]+)"); if (m.Success) { normalized = Normalize(host, m.Value); label = "TIKTOK"; return true; } return false; } // ===== Facebook ===== if (host.EndsWith("facebook.com") || host == "fb.watch") { var m = Regex.Match(path, "/(?:reel|videos)/([^/?#]+)"); string? watchId = GetQuery(url, "v"); if (m.Success) { normalized = Normalize(host == "fb.watch" ? "www.facebook.com" : host, m.Value); label = "FACEBOOK"; return true; } if (!string.IsNullOrEmpty(watchId)) { normalized = $"https://www.facebook.com/watch/?v={watchId}"; label = "FACEBOOK"; return true; } return false; } return false; } private static string? ExtractYouTubeId(string host, Uri url, string path) { if (host == "youtu.be") { var seg = path.Trim('/'); return string.IsNullOrEmpty(seg) ? null : seg; } var v = GetQuery(url, "v"); if (!string.IsNullOrEmpty(v)) return v; var m = Regex.Match(path, "/(?:shorts|embed|live)/([A-Za-z0-9_-]{11})"); if (m.Success) return m.Groups[1].Value; return null; } /// 쿼리스트링에서 단일 키 값 추출(? 제외 후 & 와 = 로 분해). private static string? GetQuery(Uri url, string key) { var query = url.Query ?? ""; if (query.StartsWith("?")) query = query[1..]; if (string.IsNullOrEmpty(query)) return null; foreach (var pair in query.Split('&')) { var eq = pair.IndexOf('='); if (eq <= 0) continue; var k = pair[..eq]; if (k.Equals(key, StringComparison.Ordinal)) return Uri.UnescapeDataString(pair[(eq + 1)..]); } return null; } /// scheme://host + 매칭된 경로(쿼리/트래킹 제거). host 에 www 보정. private static string Normalize(string host, string path) { var h = host.StartsWith("www.") ? host : "www." + host; return $"https://{h}{path}"; } }