diff --git a/API/MangaConnectors/MangaConnector.cs b/API/MangaConnectors/MangaConnector.cs index 184e740..c462856 100644 --- a/API/MangaConnectors/MangaConnector.cs +++ b/API/MangaConnectors/MangaConnector.cs @@ -16,9 +16,7 @@ namespace API.MangaConnectors; public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl) { [NotMapped] internal DownloadClient downloadClient { get; init; } = null!; - [NotMapped] protected ILog Log { get; init; } = LogManager.GetLogger(name); - [StringLength(32)] public string Name { get; init; } = name; [StringLength(8)] public string[] SupportedLanguages { get; init; } = supportedLanguages; [StringLength(2048)] public string IconUrl { get; init; } = iconUrl; diff --git a/API/MangaConnectors/MangaDex.cs b/API/MangaConnectors/MangaDex.cs index 01e0d88..e6f4188 100644 --- a/API/MangaConnectors/MangaDex.cs +++ b/API/MangaConnectors/MangaDex.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Web; using API.MangaDownloadClients; using API.Schema.MangaContext; using Newtonsoft.Json.Linq; @@ -29,7 +30,7 @@ public class MangaDex : MangaConnector while(offset < total) { string requestUrl = - $"https://api.mangadex.org/manga?limit={Limit}&offset={offset}&title={mangaSearchName}" + + $"https://api.mangadex.org/manga?limit={Limit}&offset={offset}&title={HttpUtility.UrlEncode(mangaSearchName)}" + $"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" + $"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'"; offset += Limit; diff --git a/API/MangaConnectors/MangaPark.cs b/API/MangaConnectors/MangaPark.cs new file mode 100644 index 0000000..6c356d2 --- /dev/null +++ b/API/MangaConnectors/MangaPark.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Text.RegularExpressions; +using System.Web; +using API.MangaDownloadClients; +using API.Schema.MangaContext; +using HtmlAgilityPack; +using static System.Text.RegularExpressions.Regex; + +namespace API.MangaConnectors; + +public class MangaPark : MangaConnector +{ + public MangaPark() : base("MangaPark", + ["en"], + ["mangapark.com", "mangapark.net", "mangapark.org", "mangapark.me", "mangapark.io", "mangapark.to", "comicpark.org", "comicpark.to", "readpark.org", "readpark.net", "parkmanga.com", "parkmanga.net", "parkmanga.org", "mpark.to"], + "/blahaj.png") + { + this.downloadClient = new HttpDownloadClient(); + } + + public override (Manga, MangaConnectorId)[] SearchManga(string mangaSearchName) + { + foreach (string uri in BaseUris) + if (SearchMangaWithDomain(mangaSearchName, uri) is { } result) + return result; + return []; + } + + private (Manga, MangaConnectorId)[]? SearchMangaWithDomain(string mangaSearchName, string domain) + { + Uri baseUri = new($"https://{domain}/"); + + List<(Manga, MangaConnectorId)> ret = []; + + for (int page = 1;; page++) // break; in loop + { + Uri searchUri = new(baseUri, $"search?word={HttpUtility.UrlEncode(mangaSearchName)}&lang={Tranga.Settings.DownloadLanguage}&page={page}"); + if (downloadClient.MakeRequest(searchUri.ToString(), RequestType.Default) is { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + { + HtmlDocument document= result.CreateDocument(); + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract HAP sucks with nullable types + if (document.DocumentNode.SelectSingleNode("//button[contains(text(),\"No Data\")]") is not null) // No results found + break; + + ret.AddRange(document.GetNodesWith("q4_9")?.Select(n => ParseSingleMangaFromSearchResultsList(baseUri, n))??[]); + }else + return null; + } + + return ret.ToArray(); + } + + private (Manga, MangaConnectorId) ParseSingleMangaFromSearchResultsList(Uri baseUri, HtmlNode resultNode) + { + HtmlNode titleAndLinkNode = resultNode.SelectSingleNode("//a[contains(@href,'title')]"); + string link = titleAndLinkNode.Attributes["href"].Value; + + return ((Manga, MangaConnectorId))GetMangaFromUrl(new Uri(baseUri, link).ToString())!; + } + + public override (Manga, MangaConnectorId)? GetMangaFromId(string mangaIdOnSite) + { + foreach (string uri in BaseUris) + if (GetMangaFromIdWithDomain(mangaIdOnSite, uri) is { } result) + return result; + return null; + } + + private (Manga, MangaConnectorId)? GetMangaFromIdWithDomain(string mangaIdOnSite, string domain) + { + Uri baseUri = new ($"https://{domain}/"); + return GetMangaFromUrl(new Uri(baseUri, $"title/{mangaIdOnSite}").ToString()); + } + + public override (Manga, MangaConnectorId)? GetMangaFromUrl(string url) + { + if (downloadClient.MakeRequest(url, RequestType.Default) is + { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + { + HtmlDocument document= result.CreateDocument(); + + if (document.GetNodeWith("q1_1")?.GetAttributeValue("title", string.Empty) is not { Length: >0 } name) + { + Log.Debug("Name not found."); + return null; + } + string description = HttpUtility.HtmlDecode(document.GetNodeWith("0a_9")?.InnerText ?? string.Empty); + + if (document.GetNodeWith("q1_1")?.GetAttributeValue("src", string.Empty) is not { Length: >0 } coverRelative) + { + Log.Debug("Cover not found."); + return null; + } + string coverUrl = $"{url[..url.IndexOf('/', 9)]}{coverRelative}"; + + MangaReleaseStatus releaseStatus = document.GetNodeWith("Yn_5")?.InnerText.ToLower() switch + { + "pending" => MangaReleaseStatus.Unreleased, + "ongoing" => MangaReleaseStatus.Continuing, + "completed" => MangaReleaseStatus.Completed, + "hiatus" => MangaReleaseStatus.OnHiatus, + "cancelled" => MangaReleaseStatus.Cancelled, + _ => MangaReleaseStatus.Unreleased + }; + + ICollection authors = document.GetNodeWith("tz_4")? + .ChildNodes.Where(n => n.Name == "a") + .Select(n => HttpUtility.HtmlDecode(n.InnerText)) + .Select(t => new Author(t)) + .ToList()??[]; + + ICollection mangaTags = document.GetNodesWith("kd_0")? + .Select(n => HttpUtility.HtmlDecode(n.InnerText)) + .Select(t => new MangaTag(t)) + .ToList()??[]; + + ICollection links = []; + + ICollection altTitles = document.GetNodeWith("tz_2")? + .ChildNodes.Where(n => n.InnerText.Trim().Length > 1) + .Select(n => HttpUtility.HtmlDecode(n.InnerText)) + .Select(t => new AltTitle(string.Empty, t)) + .ToList()??[]; + + Manga m = new (name, description, coverUrl, releaseStatus, authors, mangaTags, links, altTitles); + MangaConnectorId mcId = new(m, this, url.Split('/').Last(), url); + m.MangaConnectorIds.Add(mcId); + return (m, mcId); + } + else return null; + } + + public override (Chapter, MangaConnectorId)[] GetChapters(MangaConnectorId mangaId, string? language = null) + { + foreach (string uri in BaseUris) + if (GetChaptersFromDomain(mangaId, uri) is { } result) + return result; + return []; + } + + private (Chapter, MangaConnectorId)[]? GetChaptersFromDomain(MangaConnectorId mangaId, string domain) + { + Uri baseUri = new ($"https://{domain}/"); + Uri requestUri = new (baseUri, $"title/{mangaId.IdOnConnectorSite}"); + + List<(Chapter, MangaConnectorId)> ret = []; + + if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default) is + { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + { + HtmlDocument document= result.CreateDocument(); + + if (document.GetNodesWith("8t_8") is not { } chapterNodes) + { + Log.Debug("No chapters found."); + return null; + } + + foreach (HtmlNode chapterNode in chapterNodes) + { + if(ParseChapter(mangaId.Obj, chapterNode, baseUri) is { } ch) + ret.Add(ch); + } + } + else return null; + + return ret.ToArray(); + } + + private static readonly Regex VolChTitleRex = new(@"(?:.*(?:Vol\.?(?:ume)?)\s*([0-9]+))?.*(?:Ch\.?(?:apter)?)\s*((?:\d+\.)*[0-9]+)\s*(?::|-\s+(.*))?", RegexOptions.Compiled); + private (Chapter, MangaConnectorId)? ParseChapter(Manga manga, HtmlNode chapterNode, Uri baseUri) + { + HtmlNode linkNode = chapterNode.SelectSingleNode("./div[1]/a"); + string linkNodeText = HttpUtility.HtmlDecode(linkNode.InnerText); + Match linkMatch = VolChTitleRex.Match(linkNodeText); + HtmlNode? titleNode = chapterNode.SelectSingleNode("./div[1]/span"); + + string chapterNumber; + int? volumeNumber = null; + + if (!linkMatch.Success || !linkMatch.Groups[2].Success) + { + Log.Debug($"Not in standard Volume/Chapter format: {linkNodeText}"); + if (Match(linkNodeText, @"[^\d]*((?:\d+\.)*\d+)[^\d]*") is not { Success: true } match) + { + Log.Debug($"Unable to parse chapter-number: {linkNodeText}"); + return null; + } + chapterNumber = match.Groups[1].Value; + } + else + { + chapterNumber = linkMatch.Groups[2].Value; + volumeNumber = linkMatch.Groups[1].Success ? int.Parse(linkMatch.Groups[1].Value) : null; + } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract HAP sucks with nullables + string? title = titleNode is not null ? HttpUtility.HtmlDecode(titleNode.InnerText)[2..] : (linkMatch.Groups[3].Success ? linkMatch.Groups[3].Value : null); + + string url = new Uri(baseUri, linkNode.GetAttributeValue("href", "")).ToString(); + string id = linkNode.GetAttributeValue("href", "")[7..]; + + Chapter chapter = new (manga, chapterNumber, volumeNumber, title); + MangaConnectorId chId = new(chapter, this, id, url); + chapter.MangaConnectorIds.Add(chId); + + return (chapter, chId); + } + + internal override string[] GetChapterImageUrls(MangaConnectorId chapterId) + { + foreach (string uri in BaseUris) + if (GetChapterImageUrlsFromDomain(chapterId, uri) is { } result) + return result; + return []; + } + + private string[]? GetChapterImageUrlsFromDomain(MangaConnectorId chapterId, string domain) + { + Uri baseUri = new ($"https://{domain}/"); + Uri requestUri = new (baseUri, $"title/{chapterId.IdOnConnectorSite}"); + if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default) is + { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + { + HtmlDocument document = result.CreateDocument(); + + if (document.DocumentNode.SelectSingleNode("//script[@type='qwik/json']")?.InnerText is not { } imageJson) + { + Log.Debug("No images found."); + return null; + } + + MatchCollection matchCollection = Matches(imageJson, @"https?:\/\/[\da-zA-Z\.]+\/[^,""]*\.[a-z]+"); + return matchCollection.Select(m => m.Value).ToArray(); + } + else return null; + } +} + +internal static class MangaParkHelper +{ + internal static HtmlDocument CreateDocument(this RequestResult result) + { + HtmlDocument document = new(); + StreamReader sr = new (result.result); + string htmlStr = sr.ReadToEnd().Replace("q:key", "qkey"); + document.LoadHtml(htmlStr); + + return document; + } + + internal static HtmlNode? GetNodeWith(this HtmlDocument document, string search) => document.DocumentNode.SelectSingleNode("/html").GetNodeWith(search); + internal static HtmlNode? GetNodeWith(this HtmlNode node, string search) => node.SelectNodes($"{node.XPath}//*[@qkey='{search}']")?.FirstOrDefault(); + internal static HtmlNodeCollection? GetNodesWith(this HtmlDocument document, string search) => document.DocumentNode.SelectSingleNode("/html ").GetNodesWith(search); + // ReSharper disable once ReturnTypeCanBeNotNullable HAP nullable + internal static HtmlNodeCollection? GetNodesWith(this HtmlNode node, string search) => node.SelectNodes($"{node.XPath}//*[@qkey='{search}']"); +} \ No newline at end of file diff --git a/API/MangaConnectors/Mangaworld.cs b/API/MangaConnectors/Mangaworld.cs index a6d450c..e8b84cf 100644 --- a/API/MangaConnectors/Mangaworld.cs +++ b/API/MangaConnectors/Mangaworld.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Web; using API.MangaDownloadClients; using API.Schema.MangaContext; using HtmlAgilityPack; @@ -27,7 +28,7 @@ public sealed class Mangaworld : MangaConnector public override (Manga, MangaConnectorId)[] SearchManga(string mangaSearchName) { Uri baseUri = new ("https://www.mangaworld.cx/"); - Uri searchUrl = new (baseUri, "archive?keyword=" + Uri.EscapeDataString(mangaSearchName)); + Uri searchUrl = new (baseUri, "archive?keyword=" + HttpUtility.UrlEncode(mangaSearchName)); RequestResult res = downloadClient.MakeRequest(searchUrl.ToString(), RequestType.Default); if ((int)res.statusCode < 200 || (int)res.statusCode >= 300) diff --git a/API/Schema/MangaContext/MangaContext.cs b/API/Schema/MangaContext/MangaContext.cs index 4d7069e..61f3cb8 100644 --- a/API/Schema/MangaContext/MangaContext.cs +++ b/API/Schema/MangaContext/MangaContext.cs @@ -23,6 +23,7 @@ public class MangaContext(DbContextOptions options) : TrangaBaseCo .HasDiscriminator(c => c.Name) .HasValue("Global") .HasValue("MangaDex") + .HasValue("MangaPark") .HasValue("Mangaworld"); //Manga has many Chapters diff --git a/API/Tranga.cs b/API/Tranga.cs index f9561c9..b948cf6 100644 --- a/API/Tranga.cs +++ b/API/Tranga.cs @@ -20,7 +20,7 @@ public static class Tranga private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga)); internal static readonly MetadataFetcher[] MetadataFetchers = [new MyAnimeList()]; - internal static readonly MangaConnector[] MangaConnectors = [new Global(), new MangaDex(), new Mangaworld()]; + internal static readonly MangaConnector[] MangaConnectors = [new Global(), new MangaDex(), new Mangaworld(), new MangaPark()]; internal static TrangaSettings Settings = TrangaSettings.Load(); // ReSharper disable MemberCanBePrivate.Global diff --git a/README.md b/README.md index e00fcc1..dd5f184 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Tranga can download Chapters and Metadata from "Scanlation" sites such as - [MangaDex.org](https://mangadex.org/) (Multilingual) - [MangaWorld](https://www.mangaworld.cx) (it) +- [MangaPark](https://www.mangapark.com) (en) - ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+) and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/). diff --git a/Tranga.sln.DotSettings b/Tranga.sln.DotSettings index f95497a..2af9571 100644 --- a/Tranga.sln.DotSettings +++ b/Tranga.sln.DotSettings @@ -2,6 +2,7 @@ True True True + True True True True @@ -11,9 +12,13 @@ True True True + True True True + True True + True + True True True True