From d36f29b7be16a95f7d05e17c6eec15dc628cfa7d Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 5 May 2026 20:40:13 +0300 Subject: [PATCH] refactor(shared): extract player payload decoding logic into shared library Move player payload extraction and decoding logic from individual modules (AnimeON, Mikai, NMoonAnime) into a new LME.Shared library. This reduces code duplication and centralizes the parsing logic for better maintainability. The new PlayerJsDecoder class handles various player script formats including atob-encoded payloads, JSON.parse helpers, and different file payload structures. All consuming modules now reference the shared project and use the common decoder. Also fix typo in NMoonAnime Controller (Firts -> First). --- LME.AnimeON/AnimeON.csproj | 1 + LME.AnimeON/AnimeONInvoke.cs | 69 ++++- LME.Mikai/Mikai.csproj | 1 + LME.Mikai/MikaiInvoke.cs | 68 ++++- LME.NMoonAnime/Controller.cs | 2 +- LME.NMoonAnime/NMoonAnime.csproj | 1 + LME.NMoonAnime/NMoonAnimeInvoke.cs | 444 +--------------------------- LME.Shared/GlobalUsings.cs | 3 - LME.Shared/LME.Shared.csproj | 12 + LME.Shared/Models/PlayerPayload.cs | 9 + LME.Shared/PlayerJsDecoder.cs | 455 +++++++++++++++++++++++++++++ 11 files changed, 613 insertions(+), 452 deletions(-) create mode 100644 LME.Shared/LME.Shared.csproj create mode 100644 LME.Shared/Models/PlayerPayload.cs create mode 100644 LME.Shared/PlayerJsDecoder.cs diff --git a/LME.AnimeON/AnimeON.csproj b/LME.AnimeON/AnimeON.csproj index 9512ffc..245e522 100644 --- a/LME.AnimeON/AnimeON.csproj +++ b/LME.AnimeON/AnimeON.csproj @@ -10,6 +10,7 @@ ..\..\Shared.dll + \ No newline at end of file diff --git a/LME.AnimeON/AnimeONInvoke.cs b/LME.AnimeON/AnimeONInvoke.cs index eb002a3..a9e637c 100644 --- a/LME.AnimeON/AnimeONInvoke.cs +++ b/LME.AnimeON/AnimeONInvoke.cs @@ -6,11 +6,14 @@ using Shared.Models.Online.Settings; using Shared.Models; using Shared.Models.Templates; using System.Text.Json; +using System.Text.Json.Nodes; using System.Linq; using System.Text; using System.Net; using System.Text.RegularExpressions; using LME.AnimeON.Models; +using LME.Shared; +using LME.Shared.Models; using Shared.Engine; namespace LME.AnimeON @@ -183,11 +186,12 @@ namespace LME.AnimeON if (string.IsNullOrEmpty(html)) return null; - var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)"""); - if (match.Success) - { - return match.Groups[1].Value; - } + var payload = PlayerJsDecoder.ExtractPlayerPayload(html); + if (payload?.FilePayload == null) + return null; + + var streamUrls = ExtractStreamUrls(payload.FilePayload); + return streamUrls?.FirstOrDefault(); } catch (Exception ex) { @@ -197,6 +201,61 @@ namespace LME.AnimeON return null; } + private List ExtractStreamUrls(object filePayload) + { + var urls = new List(); + if (filePayload == null) + return urls; + + // Обробка string значення + if (filePayload is string strPayload) + { + urls.Add(strPayload); + return urls; + } + + // Обробка JsonValue + if (filePayload is JsonValue jsonValue && jsonValue.TryGetValue(out string strValue)) + { + urls.Add(strValue); + return urls; + } + + // Обробка JsonObject — витягти 'file' поле + if (filePayload is JsonObject objPayload) + { + if (objPayload.TryGetPropertyValue("file", out JsonNode fileNode)) + { + string fileStr = fileNode?.ToString(); + if (!string.IsNullOrEmpty(fileStr)) + urls.Add(fileStr); + } + return urls; + } + + // Обробка JsonArray + if (filePayload is JsonArray arrayPayload) + { + foreach (var item in arrayPayload) + { + if (item is JsonObject itemObj && itemObj.TryGetPropertyValue("file", out JsonNode fileProp)) + { + string fileStr = fileProp?.ToString(); + if (!string.IsNullOrEmpty(fileStr)) + urls.Add(fileStr); + } + else if (item is JsonValue itemValue && itemValue.TryGetValue(out string itemStr)) + { + if (!string.IsNullOrEmpty(itemStr)) + urls.Add(itemStr); + } + } + return urls; + } + + return urls; + } + public async Task ParseAshdiPage(string url, bool disableAshdiMultivoiceForVod = false) { var streams = await ParseAshdiPageStreams(url, disableAshdiMultivoiceForVod); diff --git a/LME.Mikai/Mikai.csproj b/LME.Mikai/Mikai.csproj index c280999..049c9e2 100644 --- a/LME.Mikai/Mikai.csproj +++ b/LME.Mikai/Mikai.csproj @@ -10,6 +10,7 @@ ..\..\Shared.dll + diff --git a/LME.Mikai/MikaiInvoke.cs b/LME.Mikai/MikaiInvoke.cs index ea44c6f..6f5fd3d 100644 --- a/LME.Mikai/MikaiInvoke.cs +++ b/LME.Mikai/MikaiInvoke.cs @@ -2,11 +2,14 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using System.Web; using System.Net; using System.Text.RegularExpressions; using LME.Mikai.Models; +using LME.Shared; +using LME.Shared.Models; using Shared; using Shared.Engine; using Shared.Models; @@ -158,16 +161,73 @@ namespace LME.Mikai if (string.IsNullOrEmpty(html)) return null; - var match = System.Text.RegularExpressions.Regex.Match(html, @"file:\s*""([^""]+\.m3u8)"""); - if (match.Success) - return match.Groups[1].Value; + var payload = PlayerJsDecoder.ExtractPlayerPayload(html); + if (payload?.FilePayload == null) + return null; + + var streamUrls = ExtractStreamUrls(payload.FilePayload); + return streamUrls?.FirstOrDefault(); } catch (Exception ex) { _onLog($"Mikai ParseMoonAnimePage error: {ex.Message}"); + return null; + } + } + + private List ExtractStreamUrls(object filePayload) + { + var urls = new List(); + if (filePayload == null) + return urls; + + // Обробка string значення + if (filePayload is string strPayload) + { + urls.Add(strPayload); + return urls; } - return null; + // Обробка JsonValue + if (filePayload is JsonValue jsonValue && jsonValue.TryGetValue(out string strValue)) + { + urls.Add(strValue); + return urls; + } + + // Обробка JsonObject — витягти 'file' поле + if (filePayload is JsonObject objPayload) + { + if (objPayload.TryGetPropertyValue("file", out JsonNode fileNode)) + { + string fileStr = fileNode?.ToString(); + if (!string.IsNullOrEmpty(fileStr)) + urls.Add(fileStr); + } + return urls; + } + + // Обробка JsonArray + if (filePayload is JsonArray arrayPayload) + { + foreach (var item in arrayPayload) + { + if (item is JsonObject itemObj && itemObj.TryGetPropertyValue("file", out JsonNode fileProp)) + { + string fileStr = fileProp?.ToString(); + if (!string.IsNullOrEmpty(fileStr)) + urls.Add(fileStr); + } + else if (item is JsonValue itemValue && itemValue.TryGetValue(out string itemStr)) + { + if (!string.IsNullOrEmpty(itemStr)) + urls.Add(itemStr); + } + } + return urls; + } + + return urls; } string AshdiRequestUrl(string url) diff --git a/LME.NMoonAnime/Controller.cs b/LME.NMoonAnime/Controller.cs index 8631584..c2841f5 100644 --- a/LME.NMoonAnime/Controller.cs +++ b/LME.NMoonAnime/Controller.cs @@ -109,7 +109,7 @@ namespace LME.NMoonAnime.Controllers if (!streamQuality.Any()) return OnError("lme_nmoonanime", refresh_proxy: true); - var first = streamQuality.Firts(); + var first = streamQuality.First(); string json = VideoTpl.ToJson("play", first.link, title ?? string.Empty, streamquality: streamQuality); return UpdateService.Validate(Content(json, "application/json; charset=utf-8")); } diff --git a/LME.NMoonAnime/NMoonAnime.csproj b/LME.NMoonAnime/NMoonAnime.csproj index c280999..049c9e2 100644 --- a/LME.NMoonAnime/NMoonAnime.csproj +++ b/LME.NMoonAnime/NMoonAnime.csproj @@ -10,6 +10,7 @@ ..\..\Shared.dll + diff --git a/LME.NMoonAnime/NMoonAnimeInvoke.cs b/LME.NMoonAnime/NMoonAnimeInvoke.cs index ded524c..ebb7325 100644 --- a/LME.NMoonAnime/NMoonAnimeInvoke.cs +++ b/LME.NMoonAnime/NMoonAnimeInvoke.cs @@ -1,4 +1,6 @@ using LME.NMoonAnime.Models; +using LME.Shared; +using LME.Shared.Models; using Shared; using Shared.Engine; using Shared.Models; @@ -29,12 +31,6 @@ namespace LME.NMoonAnime }; private static readonly Regex _reSeason = new Regex(@"(?:season|сезон)\s*(\d+)|(\d+)\s*(?:season|сезон)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex _reEpisode = new Regex(@"(?:episode|серія|серия|епізод|ep)\s*(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex _reTrailingComma = new Regex(@",\s*([}\]])", RegexOptions.Compiled); - private static readonly Regex _reAtobLiteral = new Regex(@"atob\(\s*(['""])(?[A-Za-z0-9+/=]+)\1\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex _reJsonParseHelper = new Regex(@"JSON\.parse\(\s*(?[A-Za-z_$][\w$]*)\(\s*(?['""])(?.*?)(\k)\s*\)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); - private static readonly Regex _reHelperCall = new Regex(@"^\s*(?[A-Za-z_$][\w$]*)\(\s*(?['""])(?.*?)(\k)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); - private static readonly UTF8Encoding _utf8Strict = new UTF8Encoding(false, true); - private static readonly Encoding _latin1 = Encoding.GetEncoding("ISO-8859-1"); public NMoonAnimeInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager, HttpHydra httpHydra = null) { @@ -241,7 +237,7 @@ namespace LME.NMoonAnime IsSeries = false }; - var payload = ExtractPlayerPayload(html); + var payload = PlayerJsDecoder.ExtractPlayerPayload(html); if (payload == null) return content; @@ -495,437 +491,14 @@ namespace LME.NMoonAnime return entries; } - private PlayerPayload ExtractPlayerPayload(string htmlText) - { - string cleanHtml = WebUtility.HtmlDecode(htmlText ?? string.Empty); - if (string.IsNullOrWhiteSpace(cleanHtml)) - return null; - - var candidates = new List { cleanHtml }; - string decodedScript = DecodeOuterPlayerScript(cleanHtml); - if (!string.IsNullOrWhiteSpace(decodedScript)) - candidates.Insert(0, decodedScript); - - foreach (string sourceText in candidates) - { - string objectText = ExtractObjectByBraces(sourceText, "new Playerjs"); - if (string.IsNullOrWhiteSpace(objectText)) - objectText = ExtractObjectByBraces(sourceText, "Playerjs({"); - - string searchText = string.IsNullOrWhiteSpace(objectText) ? sourceText : objectText; - - string fileValue = ExtractJsValue(searchText, "file"); - if (fileValue == null && !string.IsNullOrWhiteSpace(objectText)) - fileValue = ExtractJsValue(sourceText, "file"); - if (fileValue == null) - continue; - - string titleValue = ExtractJsValue(searchText, "title"); - object parsedFile = ParsePlayerFileValue(fileValue, sourceText); - - return new PlayerPayload - { - Title = Nullish(titleValue), - FilePayload = parsedFile - }; - } - - return null; - } - - private object ParsePlayerFileValue(string rawValue, string contextText) - { - string text = rawValue?.Trim(); - if (string.IsNullOrWhiteSpace(text)) - return rawValue; - - if (text.StartsWith("[") || text.StartsWith("{")) - { - JsonNode loaded = LoadJsonLoose(text); - if (loaded != null) - return loaded; - } - - var parseMatch = _reJsonParseHelper.Match(text); - if (parseMatch.Success) - { - string decoded = DecodeHelperPayload(parseMatch.Groups["fn"].Value, parseMatch.Groups["payload"].Value, contextText); - if (!string.IsNullOrWhiteSpace(decoded)) - { - JsonNode loaded = LoadJsonLoose(decoded); - if (loaded != null) - return loaded; - } - } - - var helperMatch = _reHelperCall.Match(text); - if (helperMatch.Success) - { - string decoded = DecodeHelperPayload(helperMatch.Groups["fn"].Value, helperMatch.Groups["payload"].Value, contextText); - if (!string.IsNullOrWhiteSpace(decoded)) - { - JsonNode loaded = LoadJsonLoose(decoded); - if (loaded != null) - return loaded; - - return decoded; - } - } - - return rawValue; - } - - private string DecodeHelperPayload(string helperName, string payload, string contextText) - { - if (string.IsNullOrWhiteSpace(helperName)) - return null; - - if (helperName.Equals("atob", StringComparison.OrdinalIgnoreCase)) - { - byte[] rawBytes = SafeBase64Decode(payload); - return rawBytes == null ? null : DecodeBytes(rawBytes); - } - - string helperKey = ExtractHelperKey(contextText, helperName); - if (string.IsNullOrWhiteSpace(helperKey)) - return null; - - byte[] keyBytes = Encoding.UTF8.GetBytes(helperKey); - if (keyBytes.Length == 0) - return null; - - byte[] payloadBytes = SafeBase64Decode(payload); - if (payloadBytes == null) - return null; - - var decoded = new byte[payloadBytes.Length]; - for (int index = 0; index < payloadBytes.Length; index++) - decoded[index] = (byte)(payloadBytes[index] ^ keyBytes[index % keyBytes.Length]); - - return DecodeBytes(decoded); - } - - private static string ExtractHelperKey(string contextText, string helperName) - { - if (string.IsNullOrWhiteSpace(contextText) || string.IsNullOrWhiteSpace(helperName)) - return null; - - string pattern = $@"function\s+{Regex.Escape(helperName)}\s*\([^)]*\)\s*\{{[\s\S]*?var\s+k\s*=\s*(['""])(?.*?)\1"; - var match = Regex.Match(contextText, pattern, RegexOptions.IgnoreCase); - if (!match.Success) - return null; - - return Nullish(match.Groups["key"].Value); - } - - private static string DecodeOuterPlayerScript(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return null; - - var match = _reAtobLiteral.Match(text); - if (!match.Success) - return null; - - byte[] rawData = SafeBase64Decode(match.Groups["payload"].Value); - if (rawData == null || rawData.Length <= 32) - return null; - - byte[] key = rawData.Take(32).ToArray(); - byte[] encryptedData = rawData.Skip(32).ToArray(); - var decoded = new byte[encryptedData.Length]; - - for (int index = 0; index < encryptedData.Length; index++) - decoded[index] = (byte)(encryptedData[index] ^ key[index % key.Length]); - - return DecodeBytes(decoded); - } - - private static byte[] SafeBase64Decode(string value) - { - string text = value?.Trim(); - if (string.IsNullOrWhiteSpace(text)) - return null; - - int remainder = text.Length % 4; - if (remainder != 0) - text += new string('=', 4 - remainder); - - try - { - return Convert.FromBase64String(text); - } - catch - { - return null; - } - } - - private static string DecodeBytes(byte[] data) - { - if (data == null || data.Length == 0) - return null; - - try - { - return _utf8Strict.GetString(data); - } - catch - { - return _latin1.GetString(data); - } - } - - private static string ExtractObjectByBraces(string text, string anchor) - { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(anchor)) - return null; - - int anchorIndex = text.IndexOf(anchor, StringComparison.OrdinalIgnoreCase); - if (anchorIndex < 0) - return null; - - int braceIndex = text.IndexOf('{', anchorIndex); - if (braceIndex < 0) - return null; - - int depth = 0; - bool escaped = false; - char? inString = null; - - for (int index = braceIndex; index < text.Length; index++) - { - char current = text[index]; - - if (inString.HasValue) - { - if (escaped) - { - escaped = false; - continue; - } - - if (current == '\\') - { - escaped = true; - continue; - } - - if (current == inString.Value) - inString = null; - - continue; - } - - if (current == '"' || current == '\'') - { - inString = current; - continue; - } - - if (current == '{') - { - depth++; - continue; - } - - if (current == '}') - { - depth--; - if (depth == 0) - return text.Substring(braceIndex + 1, index - braceIndex - 1); - } - } - - return null; - } - - private static string ExtractJsValue(string text, string key) - { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(key)) - return null; - - var match = Regex.Match(text, $@"\b{Regex.Escape(key)}\b\s*:\s*", RegexOptions.IgnoreCase); - if (!match.Success) - return null; - - int index = match.Index + match.Length; - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - - if (index >= text.Length) - return null; - - char token = text[index]; - if (token == '"' || token == '\'') - { - var (value, _) = ReadJsString(text, index); - return value; - } - - if (token == '[') - { - int endIndex = FindMatchingBracket(text, index, '[', ']'); - return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null; - } - - if (token == '{') - { - int endIndex = FindMatchingBracket(text, index, '{', '}'); - return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null; - } - - int stopIndex = index; - while (stopIndex < text.Length && text[stopIndex] != ',' && text[stopIndex] != '}' && text[stopIndex] != '\n' && text[stopIndex] != '\r') - stopIndex++; - - return text.Substring(index, stopIndex - index).Trim(); - } - - private static (string value, int nextIndex) ReadJsString(string text, int startIndex) - { - if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length) - return (null, -1); - - char quote = text[startIndex]; - if (quote != '"' && quote != '\'') - return (null, -1); - - var buffer = new StringBuilder(); - bool escaped = false; - - for (int index = startIndex + 1; index < text.Length; index++) - { - char current = text[index]; - - if (escaped) - { - buffer.Append(current); - escaped = false; - continue; - } - - if (current == '\\') - { - escaped = true; - continue; - } - - if (current == quote) - return (buffer.ToString(), index + 1); - - buffer.Append(current); - } - - return (null, -1); - } - - private static int FindMatchingBracket(string text, int startIndex, char openChar, char closeChar) - { - if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length || text[startIndex] != openChar) - return -1; - - int depth = 0; - bool escaped = false; - char? inString = null; - - for (int index = startIndex; index < text.Length; index++) - { - char current = text[index]; - - if (inString.HasValue) - { - if (escaped) - { - escaped = false; - continue; - } - - if (current == '\\') - { - escaped = true; - continue; - } - - if (current == inString.Value) - inString = null; - - continue; - } - - if (current == '"' || current == '\'') - { - inString = current; - continue; - } - - if (current == openChar) - { - depth++; - continue; - } - - if (current == closeChar) - { - depth--; - if (depth == 0) - return index; - } - } - - return -1; - } - private static JsonNode LoadJsonLoose(string value) { - string text = value?.Trim(); - if (string.IsNullOrWhiteSpace(text)) - return null; - - string normalized = WebUtility.HtmlDecode(text).Replace("\\/", "/"); - string unescapedQuotes = normalized.Replace("\\'", "'").Replace("\\\"", "\""); - var candidates = new[] - { - normalized, - unescapedQuotes, - RemoveTrailingCommas(normalized), - RemoveTrailingCommas(unescapedQuotes) - }; - - foreach (string candidate in candidates.Distinct(StringComparer.Ordinal)) - { - if (string.IsNullOrWhiteSpace(candidate)) - continue; - - try - { - return JsonNode.Parse(candidate); - } - catch - { - } - } - - return null; - } - - private static string RemoveTrailingCommas(string value) - { - return string.IsNullOrWhiteSpace(value) ? value : _reTrailingComma.Replace(value, "$1"); + return PlayerJsDecoder.LoadJsonLoose(value); } private static string Nullish(string value) { - string text = value?.Trim(); - if (string.IsNullOrWhiteSpace(text)) - return null; - - if (text.Equals("null", StringComparison.OrdinalIgnoreCase) || - text.Equals("none", StringComparison.OrdinalIgnoreCase) || - text.Equals("undefined", StringComparison.OrdinalIgnoreCase)) - return null; - - return text; + return PlayerJsDecoder.Nullish(value); } private static bool TryGetArray(JsonObject obj, string key, out JsonArray array) @@ -1120,13 +693,6 @@ namespace LME.NMoonAnime return TimeSpan.FromMinutes(ctime); } - private sealed class PlayerPayload - { - public string Title { get; set; } - - public object FilePayload { get; set; } - } - private sealed class NMoonAnimeMovieEntry { public string Title { get; set; } diff --git a/LME.Shared/GlobalUsings.cs b/LME.Shared/GlobalUsings.cs index c3d3cbf..3a1f874 100644 --- a/LME.Shared/GlobalUsings.cs +++ b/LME.Shared/GlobalUsings.cs @@ -1,6 +1,3 @@ -global using Shared.Services; -global using Shared.Services.Hybrid; -global using Shared.Models.Base; global using AppInit = Shared.CoreInit; global using LME.Common.Online; global using LME.Common.Update; diff --git a/LME.Shared/LME.Shared.csproj b/LME.Shared/LME.Shared.csproj new file mode 100644 index 0000000..393ea31 --- /dev/null +++ b/LME.Shared/LME.Shared.csproj @@ -0,0 +1,12 @@ + + + net10.0 + library + true + + + + ..\..\Shared.dll + + + diff --git a/LME.Shared/Models/PlayerPayload.cs b/LME.Shared/Models/PlayerPayload.cs new file mode 100644 index 0000000..c771668 --- /dev/null +++ b/LME.Shared/Models/PlayerPayload.cs @@ -0,0 +1,9 @@ +namespace LME.Shared.Models +{ + public sealed class PlayerPayload + { + public string Title { get; set; } + + public object FilePayload { get; set; } + } +} diff --git a/LME.Shared/PlayerJsDecoder.cs b/LME.Shared/PlayerJsDecoder.cs new file mode 100644 index 0000000..dcaec37 --- /dev/null +++ b/LME.Shared/PlayerJsDecoder.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using LME.Shared.Models; + +namespace LME.Shared +{ + public static class PlayerJsDecoder + { + private static readonly Regex _reAtobLiteral = new Regex(@"atob\(\s*(['""])(?[A-Za-z0-9+/=]+)\1\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex _reJsonParseHelper = new Regex(@"JSON\.parse\(\s*(?[A-Za-z_$][\w$]*)\(\s*(?['""])(?.*?)(\k)\s*\)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex _reHelperCall = new Regex(@"^\s*(?[A-Za-z_$][\w$]*)\(\s*(?['""])(?.*?)(\k)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex _reTrailingComma = new Regex(@",\s*([}\]])", RegexOptions.Compiled); + private static readonly UTF8Encoding _utf8Strict = new UTF8Encoding(false, true); + private static readonly Encoding _latin1 = Encoding.GetEncoding("ISO-8859-1"); + + public static PlayerPayload ExtractPlayerPayload(string htmlText) + { + string cleanHtml = WebUtility.HtmlDecode(htmlText ?? string.Empty); + if (string.IsNullOrWhiteSpace(cleanHtml)) + return null; + + var candidates = new List { cleanHtml }; + string decodedScript = DecodeOuterPlayerScript(cleanHtml); + if (!string.IsNullOrWhiteSpace(decodedScript)) + candidates.Insert(0, decodedScript); + + foreach (string sourceText in candidates) + { + string objectText = ExtractObjectByBraces(sourceText, "new Playerjs"); + if (string.IsNullOrWhiteSpace(objectText)) + objectText = ExtractObjectByBraces(sourceText, "Playerjs({"); + + string searchText = string.IsNullOrWhiteSpace(objectText) ? sourceText : objectText; + + string fileValue = ExtractJsValue(searchText, "file"); + if (fileValue == null && !string.IsNullOrWhiteSpace(objectText)) + fileValue = ExtractJsValue(sourceText, "file"); + if (fileValue == null) + continue; + + string titleValue = ExtractJsValue(searchText, "title"); + object parsedFile = ParsePlayerFileValue(fileValue, sourceText); + + return new PlayerPayload + { + Title = Nullish(titleValue), + FilePayload = parsedFile + }; + } + + return null; + } + + private static object ParsePlayerFileValue(string rawValue, string contextText) + { + string text = rawValue?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return rawValue; + + if (text.StartsWith("[") || text.StartsWith("{")) + { + JsonNode loaded = LoadJsonLoose(text); + if (loaded != null) + return loaded; + } + + var parseMatch = _reJsonParseHelper.Match(text); + if (parseMatch.Success) + { + string decoded = DecodeHelperPayload(parseMatch.Groups["fn"].Value, parseMatch.Groups["payload"].Value, contextText); + if (!string.IsNullOrWhiteSpace(decoded)) + { + JsonNode loaded = LoadJsonLoose(decoded); + if (loaded != null) + return loaded; + } + } + + var helperMatch = _reHelperCall.Match(text); + if (helperMatch.Success) + { + string decoded = DecodeHelperPayload(helperMatch.Groups["fn"].Value, helperMatch.Groups["payload"].Value, contextText); + if (!string.IsNullOrWhiteSpace(decoded)) + { + JsonNode loaded = LoadJsonLoose(decoded); + if (loaded != null) + return loaded; + + return decoded; + } + } + + return rawValue; + } + + private static string DecodeHelperPayload(string helperName, string payload, string contextText) + { + if (string.IsNullOrWhiteSpace(helperName)) + return null; + + if (helperName.Equals("atob", StringComparison.OrdinalIgnoreCase)) + { + byte[] rawBytes = SafeBase64Decode(payload); + return rawBytes == null ? null : DecodeBytes(rawBytes); + } + + string helperKey = ExtractHelperKey(contextText, helperName); + if (string.IsNullOrWhiteSpace(helperKey)) + return null; + + byte[] keyBytes = Encoding.UTF8.GetBytes(helperKey); + if (keyBytes.Length == 0) + return null; + + byte[] payloadBytes = SafeBase64Decode(payload); + if (payloadBytes == null) + return null; + + var decoded = new byte[payloadBytes.Length]; + for (int index = 0; index < payloadBytes.Length; index++) + decoded[index] = (byte)(payloadBytes[index] ^ keyBytes[index % keyBytes.Length]); + + return DecodeBytes(decoded); + } + + private static string ExtractHelperKey(string contextText, string helperName) + { + if (string.IsNullOrWhiteSpace(contextText) || string.IsNullOrWhiteSpace(helperName)) + return null; + + string pattern = $@"function\s+{Regex.Escape(helperName)}\s*\([^)]*\)\s*\{{[\s\S]*?var\s+k\s*=\s*(['""])(?.*?)\1"; + var match = Regex.Match(contextText, pattern, RegexOptions.IgnoreCase); + if (!match.Success) + return null; + + return Nullish(match.Groups["key"].Value); + } + + private static string DecodeOuterPlayerScript(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var match = _reAtobLiteral.Match(text); + if (!match.Success) + return null; + + byte[] rawData = SafeBase64Decode(match.Groups["payload"].Value); + if (rawData == null || rawData.Length <= 32) + return null; + + byte[] key = rawData.Take(32).ToArray(); + byte[] encryptedData = rawData.Skip(32).ToArray(); + var decoded = new byte[encryptedData.Length]; + + for (int index = 0; index < encryptedData.Length; index++) + decoded[index] = (byte)(encryptedData[index] ^ key[index % key.Length]); + + return DecodeBytes(decoded); + } + + private static byte[] SafeBase64Decode(string value) + { + string text = value?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return null; + + int remainder = text.Length % 4; + if (remainder != 0) + text += new string('=', 4 - remainder); + + try + { + return Convert.FromBase64String(text); + } + catch + { + return null; + } + } + + private static string DecodeBytes(byte[] data) + { + if (data == null || data.Length == 0) + return null; + + try + { + return _utf8Strict.GetString(data); + } + catch + { + return _latin1.GetString(data); + } + } + + private static string ExtractObjectByBraces(string text, string anchor) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(anchor)) + return null; + + int anchorIndex = text.IndexOf(anchor, StringComparison.OrdinalIgnoreCase); + if (anchorIndex < 0) + return null; + + int braceIndex = text.IndexOf('{', anchorIndex); + if (braceIndex < 0) + return null; + + int depth = 0; + bool escaped = false; + char? inString = null; + + for (int index = braceIndex; index < text.Length; index++) + { + char current = text[index]; + + if (inString.HasValue) + { + if (escaped) + { + escaped = false; + continue; + } + + if (current == '\\') + { + escaped = true; + continue; + } + + if (current == inString.Value) + inString = null; + + continue; + } + + if (current == '"' || current == '\'') + { + inString = current; + continue; + } + + if (current == '{') + { + depth++; + continue; + } + + if (current == '}') + { + depth--; + if (depth == 0) + return text.Substring(braceIndex + 1, index - braceIndex - 1); + } + } + + return null; + } + + private static string ExtractJsValue(string text, string key) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(key)) + return null; + + var match = Regex.Match(text, $@"\b{Regex.Escape(key)}\b\s*:\s*", RegexOptions.IgnoreCase); + if (!match.Success) + return null; + + int index = match.Index + match.Length; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + if (index >= text.Length) + return null; + + char token = text[index]; + if (token == '"' || token == '\'') + { + var (value, _) = ReadJsString(text, index); + return value; + } + + if (token == '[') + { + int endIndex = FindMatchingBracket(text, index, '[', ']'); + return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null; + } + + if (token == '{') + { + int endIndex = FindMatchingBracket(text, index, '{', '}'); + return endIndex >= index ? text.Substring(index, endIndex - index + 1) : null; + } + + int stopIndex = index; + while (stopIndex < text.Length && text[stopIndex] != ',' && text[stopIndex] != '}' && text[stopIndex] != '\n' && text[stopIndex] != '\r') + stopIndex++; + + return text.Substring(index, stopIndex - index).Trim(); + } + + private static (string value, int nextIndex) ReadJsString(string text, int startIndex) + { + if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length) + return (null, -1); + + char quote = text[startIndex]; + if (quote != '"' && quote != '\'') + return (null, -1); + + var buffer = new StringBuilder(); + bool escaped = false; + + for (int index = startIndex + 1; index < text.Length; index++) + { + char current = text[index]; + + if (escaped) + { + buffer.Append(current); + escaped = false; + continue; + } + + if (current == '\\') + { + escaped = true; + continue; + } + + if (current == quote) + return (buffer.ToString(), index + 1); + + buffer.Append(current); + } + + return (null, -1); + } + + private static int FindMatchingBracket(string text, int startIndex, char openChar, char closeChar) + { + if (string.IsNullOrWhiteSpace(text) || startIndex < 0 || startIndex >= text.Length || text[startIndex] != openChar) + return -1; + + int depth = 0; + bool escaped = false; + char? inString = null; + + for (int index = startIndex; index < text.Length; index++) + { + char current = text[index]; + + if (inString.HasValue) + { + if (escaped) + { + escaped = false; + continue; + } + + if (current == '\\') + { + escaped = true; + continue; + } + + if (current == inString.Value) + inString = null; + + continue; + } + + if (current == '"' || current == '\'') + { + inString = current; + continue; + } + + if (current == openChar) + { + depth++; + continue; + } + + if (current == closeChar) + { + depth--; + if (depth == 0) + return index; + } + } + + return -1; + } + + public static JsonNode LoadJsonLoose(string value) + { + string text = value?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return null; + + string normalized = WebUtility.HtmlDecode(text).Replace("\\/", "/"); + string unescapedQuotes = normalized.Replace("\\'", "'").Replace("\\\"", "\""); + var candidates = new[] + { + normalized, + unescapedQuotes, + RemoveTrailingCommas(normalized), + RemoveTrailingCommas(unescapedQuotes) + }; + + foreach (string candidate in candidates.Distinct(StringComparer.Ordinal)) + { + if (string.IsNullOrWhiteSpace(candidate)) + continue; + + try + { + return JsonNode.Parse(candidate); + } + catch + { + } + } + + return null; + } + + private static string RemoveTrailingCommas(string value) + { + return string.IsNullOrWhiteSpace(value) ? value : _reTrailingComma.Replace(value, "$1"); + } + + public static string Nullish(string value) + { + string text = value?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return null; + + if (text.Equals("null", StringComparison.OrdinalIgnoreCase) || + text.Equals("none", StringComparison.OrdinalIgnoreCase) || + text.Equals("undefined", StringComparison.OrdinalIgnoreCase)) + return null; + + return text; + } + } +}