using System.Text; using System.Text.RegularExpressions; using FozFc.Api.Infrastructure.Zerozero; namespace FozFc.Api.Application.Zerozero; /// /// Image URL helpers ported from the ZeroZero worker: normalises zerozero.pt / /// staticzz CDN images to higher resolution and rewrites images that must be /// proxied through this backend (/api/media/r/<base64url>). /// public static class ZerozeroImages { public static string? ToAbsoluteUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return null; if (url.StartsWith("http://") || url.StartsWith("https://")) return url; if (url.StartsWith("//")) return $"https:{url}"; return $"{ZerozeroSite.Origin}{(url.StartsWith("/") ? "" : "/")}{url}"; } public static string UpgradeLogoUrl(string url) { foreach (var re in new[] { @"/logos/equipas/(.+)$", @"/img/logos/equipas/(.+)$" }) { var m = Regex.Match(url, re, RegexOptions.IgnoreCase); if (m.Success) return $"https://cdn-img.staticzz.com/img/logos/equipas/{m.Groups[1].Value}"; } return url; } public static string UpgradeNewsImageToStaticzzCdn(string url) { try { var u = new Uri(url); var host = u.Host.ToLowerInvariant(); if (host != "zerozero.pt" && !host.EndsWith(".zerozero.pt")) return url; var m = Regex.Match(u.AbsolutePath, @"/img/(news|noticias|galerias)/(.+)$", RegexOptions.IgnoreCase); if (!m.Success) return url; return $"https://cdn-img.staticzz.com/img/{m.Groups[1].Value.ToLowerInvariant()}/{m.Groups[2].Value}"; } catch { return url; } } private const int NoticiasImgTargetMin = 620; public static string NormalizeNoticiasHeroImageUrl(string url) { try { var u = new Uri(url); var m = Regex.Match(u.AbsolutePath, @"^(/img/(?:noticias|news)/\d+/)imgS(\d+)(I\d+T[^/]+\.(?:png|jpe?g|webp))$", RegexOptions.IgnoreCase); if (!m.Success) return url; var w = int.TryParse(m.Groups[2].Value, out var parsed) ? parsed : 0; var targetW = w < NoticiasImgTargetMin ? NoticiasImgTargetMin : w; return $"https://cdn-img.staticzz.com{m.Groups[1].Value}imgS{targetW}{m.Groups[3].Value}"; } catch { return url; } } private static bool LooksLikeNewsThumbUrl(string url) => Regex.IsMatch(url, @"_(thumb|thumbs|t|sm|mini)(\.|$|/)", RegexOptions.IgnoreCase) || Regex.IsMatch(url, @"/thumb|thumbnails?|/mini|/small/", RegexOptions.IgnoreCase) || Regex.IsMatch(url, @"[?&]w=(?:[1-9]|[1-9]\d|1\d{2}|2\d{2}|300)\b", RegexOptions.IgnoreCase) || Regex.IsMatch(url, @"[?&]h=(?:[1-9]|[1-9]\d|1\d{2}|2\d{2}|300)\b", RegexOptions.IgnoreCase); private static double ScoreNewsHeroCandidate(string url) { double s = 0; var lower = url.ToLowerInvariant(); if (LooksLikeNewsThumbUrl(url)) s -= 80; if (lower.Contains("cdn-img.staticzz") || lower.Contains("cdn-img.zerozero")) s += 25; if (Regex.IsMatch(url, @"\b(1200|1000|960|900|800|720|640)\b")) s += 20; var imgS = Regex.Match(url, @"imgS(\d+)", RegexOptions.IgnoreCase); if (imgS.Success && int.TryParse(imgS.Groups[1].Value, out var ww)) s += Math.Min(ww / 8.0, 45); s += Math.Min(url.Length, 320) * 0.04; return s; } public static string? PickNewsHeroImage(IEnumerable candidates) { var urls = candidates .Select(c => c?.Trim()) .Where(c => !string.IsNullOrEmpty(c)) .Select(c => ToAbsoluteUrl(c) ?? c!) .Where(c => !string.IsNullOrEmpty(c)) .ToList(); var seen = new HashSet(); var unique = new List(); foreach (var u in urls) { var norm = u!.Split('?')[0].ToLowerInvariant(); if (seen.Add(norm)) unique.Add(u); } if (unique.Count == 0) return null; return unique.OrderByDescending(ScoreNewsHeroCandidate).First(); } public static string? TryUpscaleNewsMediaUrl(string? url) { if (string.IsNullOrEmpty(url)) return url; try { var u = new Uri(url); var host = u.Host.ToLowerInvariant(); var isZz = host is "zerozero.pt" or "cdn-img.staticzz.com" or "cdn-img.zerozero.pt" || host.EndsWith(".zerozero.pt"); if (!isZz) return url; var changed = false; var result = Regex.Replace(url, @"([?&]w=)(\d+)", m => { var n = int.Parse(m.Groups[2].Value); if (n > 0 && n < 900) { changed = true; return m.Groups[1].Value + "1200"; } return m.Value; }, RegexOptions.IgnoreCase); result = Regex.Replace(result, @"([?&]h=)(\d+)", m => { var n = int.Parse(m.Groups[2].Value); if (n > 0 && n < 900) { changed = true; return m.Groups[1].Value + "800"; } return m.Value; }, RegexOptions.IgnoreCase); if (changed) return result; var sm = Regex.Match(u.AbsolutePath, @"^(.+)_s(\.(jpe?g|png|webp))$", RegexOptions.IgnoreCase); if (sm.Success) return url.Replace(u.AbsolutePath, sm.Groups[1].Value + sm.Groups[2].Value); var md = Regex.Match(u.AbsolutePath, @"^(.+)_m(\.(jpe?g|png|webp))$", RegexOptions.IgnoreCase); if (md.Success) return url.Replace(u.AbsolutePath, md.Groups[1].Value + md.Groups[2].Value); } catch { return url; } return url; } public static string? PickLargestFromSrcset(string? srcset) { if (string.IsNullOrWhiteSpace(srcset)) return null; string? best = null; var bestW = 0; foreach (var raw in srcset.Split(',')) { var p = raw.Trim(); if (p.Length == 0) continue; var wMatch = Regex.Match(p, @"\s+(\d+)w\s*$", RegexOptions.IgnoreCase); var xMatch = Regex.Match(p, @"\s+([\d.]+)x\s*$", RegexOptions.IgnoreCase); int w; string urlPart; if (wMatch.Success) { w = int.Parse(wMatch.Groups[1].Value); urlPart = p[..^wMatch.Value.Length].Trim(); } else if (xMatch.Success) { w = (int)Math.Round(double.Parse(xMatch.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture) * 960); urlPart = p[..^xMatch.Value.Length].Trim(); } else { urlPart = p.Split(' ', '\t')[0]; w = 1; } var url = urlPart.Trim('"', '\''); if (string.IsNullOrEmpty(url)) continue; string? abs = url.StartsWith("//") ? $"https:{url}" : Regex.IsMatch(url, @"^https?://", RegexOptions.IgnoreCase) ? url : url.StartsWith("/") ? $"{ZerozeroSite.Origin}{url}" : null; if (abs is null) continue; if (w >= bestW) { bestW = w; best = abs; } } return best; } /// /// Rewrites an image URL: high-res CDN when possible, otherwise proxied through /// this backend at {origin}/api/media/r/<base64url>. /// public static string? RewriteImageUrl(string? url, string origin) { if (string.IsNullOrEmpty(url)) return url; try { var parsed = new Uri(url); var host = parsed.Host.ToLowerInvariant(); if (host is "cdn-img.staticzz.com" or "cdn-img.zerozero.pt") return NormalizeNoticiasHeroImageUrl(url); if (host == "zerozero.pt" || host.EndsWith(".zerozero.pt")) { var upgradedLogo = UpgradeLogoUrl(url); if (upgradedLogo != url) return NormalizeNoticiasHeroImageUrl(upgradedLogo); var upgradedNews = UpgradeNewsImageToStaticzzCdn(url); if (upgradedNews != url) return NormalizeNoticiasHeroImageUrl(upgradedNews); var encoded = Base64UrlEncode(url); return $"{origin.TrimEnd('/')}/api/media/r/{encoded}"; } } catch { /* keep as-is */ } return NormalizeNoticiasHeroImageUrl(url); } public static string Base64UrlEncode(string value) { var bytes = Encoding.UTF8.GetBytes(value); return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } public static string Base64UrlDecode(string value) { var s = value.Replace('-', '+').Replace('_', '/'); switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Encoding.UTF8.GetString(Convert.FromBase64String(s)); } }