Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

View file

@ -0,0 +1,136 @@
using System.Text.RegularExpressions;
namespace VideoDownloader.App.Detection;
/// <summary>
/// 소셜 영상 페이지(YouTube/Shorts/Instagram/X/TikTok/Facebook) 판별 + URL 정규화.
/// yt-dlp 로 처리 가능한 단일 영상 URL 인지 검사하고, 같은 영상의 여러 URL 형태를
/// 하나의 정규화된 키로 통일(중복 등록 방지). 홈/검색/채널 페이지는 거짓.
/// </summary>
public static class SocialSiteDetector
{
private static readonly Regex YtId = new(@"^[A-Za-z0-9_-]{11}$", RegexOptions.Compiled);
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
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/<id>/ , /reels/<id>/ , /p/<id>/ , /tv/<id>/ , /stories/<u>/<id>/
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;
}
/// <summary>쿼리스트링에서 단일 키 값 추출(? 제외 후 & 와 = 로 분해).</summary>
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;
}
/// <summary>scheme://host + 매칭된 경로(쿼리/트래킹 제거). host 에 www 보정.</summary>
private static string Normalize(string host, string path)
{
var h = host.StartsWith("www.") ? host : "www." + host;
return $"https://{h}{path}";
}
}