releases/src/Paca.Core/Detection/SocialSiteDetector.cs
Yun Chan 8677853822
Some checks failed
release / test (push) Failing after 14m5s
release / windows (push) Has been cancelled
release / android (push) Has been cancelled
release / linux (push) Has been cancelled
release / macos (push) Has been cancelled
test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
2026-09-18 23:32:32 +09:00

180 lines
7 KiB
C#

using System.Text.RegularExpressions;
namespace Paca.Core.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);
private static readonly Regex YtPathRe = new(@"/(?:shorts|embed|live)/([A-Za-z0-9_-]{11})", RegexOptions.Compiled);
private static readonly Regex InstaPathRe = new(@"/(?:reels?|p|tv)/([^/?#]+)", RegexOptions.Compiled);
private static readonly Regex InstaStoryRe = new(@"/stories/([^/]+)/([^/?#]+)", RegexOptions.Compiled);
private static readonly Regex XPathRe = new(@"/[^/]+/status/([0-9]+)", RegexOptions.Compiled);
private static readonly Regex TikTokPathRe = new(@"/@[^/]+/video/([0-9]+)", RegexOptions.Compiled);
private static readonly Regex FbPathRe = new(@"/(?:reel|videos)/([^/?#]+)", RegexOptions.Compiled);
/// <summary>문자열 URL이 소셜 영상 URL이면 정규화된 URL과 사이트 라벨을 반환.</summary>
public static bool TryMatch(string? urlString, out string normalized, out string label)
{
normalized = "";
label = "";
if (string.IsNullOrWhiteSpace(urlString)) return false;
urlString = urlString.Trim();
if (urlString.Contains('\0')) return false;
if (urlString.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("blob:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri))
return false;
return TryMatch(uri, out normalized, out label);
}
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
public static bool TryMatch(Uri url, out string normalized, out string label)
{
normalized = "";
label = "";
if (url == null || !url.IsAbsoluteUri) return false;
if (url.Scheme != "http" && url.Scheme != "https") return false;
var host = (url.Host ?? "").ToLowerInvariant();
var path = url.AbsolutePath ?? "";
// ===== YouTube / Shorts =====
if (host == "youtube.com" || host.EndsWith(".youtube.com") || host == "youtu.be" ||
host == "youtube-nocookie.com" || host.EndsWith(".youtube-nocookie.com"))
{
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 == "instagram.com" || host.EndsWith(".instagram.com"))
{
// /reel/<id>/ , /reels/<id>/ , /p/<id>/ , /tv/<id>/ , /stories/<u>/<id>/
var m = InstaPathRe.Match(path);
if (!m.Success) m = InstaStoryRe.Match(path);
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 = XPathRe.Match(path);
if (m.Success)
{
normalized = $"https://x.com{m.Value}";
label = "X";
return true;
}
return false;
}
// ===== TikTok =====
if (host == "tiktok.com" || host.EndsWith(".tiktok.com"))
{
var m = TikTokPathRe.Match(path);
if (m.Success)
{
normalized = Normalize(host, m.Value);
label = "TIKTOK";
return true;
}
return false;
}
// ===== Facebook =====
if (host == "facebook.com" || host.EndsWith(".facebook.com") || host == "fb.watch")
{
var m = FbPathRe.Match(path);
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 = YtPathRe.Match(path);
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 에 표준 도메인 보정.</summary>
private static string Normalize(string host, string path)
{
string h;
if (host.EndsWith("instagram.com", StringComparison.OrdinalIgnoreCase))
h = "www.instagram.com";
else if (host.EndsWith("tiktok.com", StringComparison.OrdinalIgnoreCase))
h = "www.tiktok.com";
else if (host.EndsWith("facebook.com", StringComparison.OrdinalIgnoreCase) || host == "fb.watch")
h = "www.facebook.com";
else if (host.EndsWith("twitter.com", StringComparison.OrdinalIgnoreCase) || host == "x.com" || host.EndsWith(".x.com", StringComparison.OrdinalIgnoreCase))
h = "x.com";
else
h = host.StartsWith("www.") ? host : "www." + host;
return $"https://{h}{path}";
}
}