From ec1713c95f577615247454b5fb62211e215dce86 Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Wed, 21 May 2025 19:39:09 -0400 Subject: [PATCH] Add project files. --- MangaReader.Core/Data/Manga.cs | 320 ++ MangaReader.Core/HttpService/HttpService.cs | 6 + MangaReader.Core/HttpService/IHttpService.cs | 6 + MangaReader.Core/MangaReader.Core.csproj | 18 + .../WebCrawlers/IMangaWebCrawler.cs | 6 + .../WebCrawlers/MangaChapterDTO.cs | 11 + MangaReader.Core/WebCrawlers/MangaDTO.cs | 16 + .../MangaNato/MangaNatoMangaDocument.cs | 61 + .../MangaNato/MangaNatoWebCrawler.cs | 160 + MangaReader.Core/WebCrawlers/MangaStatus.cs | 8 + .../WebCrawlers/MangaWebCrawler.cs | 18 + .../NatoManga/NatoMangaHtmlDocument.cs | 21 + .../NatoManga/NatoMangaWebCrawler.cs | 186 + MangaReader.Core/WebSearch/IMangaWebSearch.cs | 11 + .../WebSearch/MangaSearchResult.cs | 9 + .../WebSearch/MangaWebSearchBase.cs | 33 + .../NatoManga/NatoMangaSearchResult.cs | 12 + .../WebSearch/NatoManga/NatoMangaWebSearch.cs | 25 + MangaReader.Tests/MangaReader.Tests.csproj | 53 + MangaReader.Tests/Utilities/ResourceHelper.cs | 23 + .../NatoManga/NatoMangaWebCrawlerTests.cs | 52 + .../NatoManga/SampleMangaPage.html | 3112 +++++++++++++++++ ...angaNato - Please Go Home, Akutsu-San!.htm | 1533 ++++++++ MangaReader.Tests/WebCrawlers/UnitTest1.cs | 60 + .../NatoManga/NatoMangaWebSearchTests.cs | 32 + .../NatoManga/SampleSearchResult.json | 20 + MangaReader.sln | 31 + 27 files changed, 5843 insertions(+) create mode 100644 MangaReader.Core/Data/Manga.cs create mode 100644 MangaReader.Core/HttpService/HttpService.cs create mode 100644 MangaReader.Core/HttpService/IHttpService.cs create mode 100644 MangaReader.Core/MangaReader.Core.csproj create mode 100644 MangaReader.Core/WebCrawlers/IMangaWebCrawler.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaChapterDTO.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaDTO.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaNato/MangaNatoMangaDocument.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaNato/MangaNatoWebCrawler.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaStatus.cs create mode 100644 MangaReader.Core/WebCrawlers/MangaWebCrawler.cs create mode 100644 MangaReader.Core/WebCrawlers/NatoManga/NatoMangaHtmlDocument.cs create mode 100644 MangaReader.Core/WebCrawlers/NatoManga/NatoMangaWebCrawler.cs create mode 100644 MangaReader.Core/WebSearch/IMangaWebSearch.cs create mode 100644 MangaReader.Core/WebSearch/MangaSearchResult.cs create mode 100644 MangaReader.Core/WebSearch/MangaWebSearchBase.cs create mode 100644 MangaReader.Core/WebSearch/NatoManga/NatoMangaSearchResult.cs create mode 100644 MangaReader.Core/WebSearch/NatoManga/NatoMangaWebSearch.cs create mode 100644 MangaReader.Tests/MangaReader.Tests.csproj create mode 100644 MangaReader.Tests/Utilities/ResourceHelper.cs create mode 100644 MangaReader.Tests/WebCrawlers/NatoManga/NatoMangaWebCrawlerTests.cs create mode 100644 MangaReader.Tests/WebCrawlers/NatoManga/SampleMangaPage.html create mode 100644 MangaReader.Tests/WebCrawlers/Samples/MangaNato - Please Go Home, Akutsu-San!.htm create mode 100644 MangaReader.Tests/WebCrawlers/UnitTest1.cs create mode 100644 MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs create mode 100644 MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json create mode 100644 MangaReader.sln diff --git a/MangaReader.Core/Data/Manga.cs b/MangaReader.Core/Data/Manga.cs new file mode 100644 index 0000000..cc69fa9 --- /dev/null +++ b/MangaReader.Core/Data/Manga.cs @@ -0,0 +1,320 @@ +using Microsoft.EntityFrameworkCore; + +namespace MangaReader.Core.Data; + +public class Manga +{ + public int MangaId { get; set; } + public required string Slug { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + + public virtual ICollection Covers { get; set; } + public virtual ICollection Titles { get; set; } + public virtual ICollection Sources { get; set; } + public virtual ICollection Genres { get; set; } + public virtual ICollection Chapters { get; set; } + + public Manga() + { + Covers = new HashSet(); + Titles = new HashSet(); + Sources = new HashSet(); + Genres = new HashSet(); + Chapters = new HashSet(); + } +} + +public class MangaCover +{ + public int MangaCoverId { get; set; } + + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public required Guid Guid { get; set; } + public required string FileExtension { get; set; } + public string? Description { get; set; } + public bool IsPrimary { get; set; } +} + +public class MangaTitle +{ + public int MangaTitleId { get; set; } + + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public required string TitleEntry { get; set; } + public TitleType TitleType { get; set; } +} + +public enum TitleType +{ + Primary, + OfficialTranslation, + FanTranslation, + Synonym, + Abbreviation, + Romaji, + Japanese +} + +public class Source +{ + public int SourceId { get; set; } + public required string Name { get; set; } +} + +public class MangaSource +{ + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public int SourceId { get; set; } + public required Source Source { get; set; } + + public required string Url { get; set; } +} + +public class Genre +{ + public int GenreId { get; set; } + public required string Name { get; set; } +} + +public class MangaGenre +{ + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public int GenreId { get; set; } + public required Genre Genre { get; set; } +} + +public class MangaChapter +{ + public int MangaChapterId { get; set; } + + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public int? VolumeNumber { get; set; } + public int ChapterNumber { get; set; } + public string? Title { get; set; } + + public virtual ICollection Sources { get; set; } = []; + public virtual ICollection Pages { get; set; } = []; +} + +public class ChapterSource +{ + public int MangaChapterId { get; set; } + public required MangaChapter Chapter { get; set; } + + public int SourceId { get; set; } + public required Source Source { get; set; } + + public required string Url { get; set; } +} + +public class ChapterPage +{ + public int ChapterPageId { get; set; } + + public int MangaChapterId { get; set; } + public required MangaChapter MangaChapter { get; set; } + + public int PageNumber { get; set; } +} + + +public class MangaContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Mangas { get; set; } + public DbSet MangaCovers { get; set; } + public DbSet MangaTitles { get; set; } + public DbSet Sources { get; set; } + public DbSet MangaSources { get; set; } + public DbSet Genres { get; set; } + public DbSet MangaGenres { get; set; } + public DbSet MangaChapters { get; set; } + public DbSet ChapterSources { get; set; } + public DbSet ChapterPages { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + ConfigureManga(modelBuilder); + ConfigureMangaCover(modelBuilder); + ConfigureMangaTitle(modelBuilder); + ConfigureSource(modelBuilder); + ConfigureMangaSource(modelBuilder); + ConfigureGenre(modelBuilder); + ConfigureMangaGenre(modelBuilder); + ConfigureMangaChapter(modelBuilder); + ConfigureChapterSource(modelBuilder); + ConfigureChapterPage(modelBuilder); + } + + private static void ConfigureManga(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.MangaId); + + modelBuilder.Entity() + .HasIndex(x => x.Slug) + .IsUnique(); + } + + private static void ConfigureMangaCover(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.MangaCoverId); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Covers) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(x => x.Guid) + .IsUnique(); + + //modelBuilder + // .Entity() + // .HasIndex(x => new { x.MangaId, x.IsPrimary }) + // .IsUnique() + // .HasFilter("[IsPrimary] = 1"); // Enforce only one primary cover per manga + } + + private static void ConfigureMangaTitle(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(mangaTitle => mangaTitle.MangaTitleId); + + modelBuilder.Entity() + .HasIndex(mangaTitle => new { mangaTitle.MangaId, mangaTitle.TitleEntry }) + .IsUnique(); + + modelBuilder + .Entity() + .HasIndex(mangaTitle => mangaTitle.TitleEntry); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Titles) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureSource(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.SourceId); + + modelBuilder + .Entity() + .HasIndex(x => x.Name) + .IsUnique(true); + } + + private static void ConfigureMangaSource(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(mangaSource => new { mangaSource.MangaId, mangaSource.SourceId }); + + modelBuilder.Entity() + .HasIndex(x => x.Url) + .IsUnique(); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Sources) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureGenre(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.GenreId); + + modelBuilder + .Entity() + .HasIndex(x => x.Name) + .IsUnique(true); + } + + private static void ConfigureMangaGenre(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(mangaGenre => new { mangaGenre.MangaId, mangaGenre.GenreId }); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Genres) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureMangaChapter(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.MangaChapterId); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Chapters) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureChapterSource(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(chapterSource => new { chapterSource.MangaChapterId, chapterSource.SourceId }); + + modelBuilder + .Entity() + .HasOne(x => x.Chapter) + .WithMany(x => x.Sources) + .HasForeignKey(x => x.MangaChapterId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigureChapterPage(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(chapterPage => chapterPage.ChapterPageId); + + modelBuilder + .Entity() + .HasIndex(chapterPage => new { chapterPage.MangaChapterId, chapterPage.PageNumber }) + .IsUnique(true); + + modelBuilder + .Entity() + .HasOne(x => x.MangaChapter) + .WithMany(x => x.Pages) + .HasForeignKey(x => x.MangaChapterId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/MangaReader.Core/HttpService/HttpService.cs b/MangaReader.Core/HttpService/HttpService.cs new file mode 100644 index 0000000..eb87146 --- /dev/null +++ b/MangaReader.Core/HttpService/HttpService.cs @@ -0,0 +1,6 @@ +namespace MangaReader.Core.HttpService; + +public class HttpService(HttpClient httpClient) : IHttpService +{ + public Task GetStringAsync(string url) => httpClient.GetStringAsync(url); +} \ No newline at end of file diff --git a/MangaReader.Core/HttpService/IHttpService.cs b/MangaReader.Core/HttpService/IHttpService.cs new file mode 100644 index 0000000..877666e --- /dev/null +++ b/MangaReader.Core/HttpService/IHttpService.cs @@ -0,0 +1,6 @@ +namespace MangaReader.Core.HttpService; + +public interface IHttpService +{ + Task GetStringAsync(string url); +} \ No newline at end of file diff --git a/MangaReader.Core/MangaReader.Core.csproj b/MangaReader.Core/MangaReader.Core.csproj new file mode 100644 index 0000000..32dae53 --- /dev/null +++ b/MangaReader.Core/MangaReader.Core.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/MangaReader.Core/WebCrawlers/IMangaWebCrawler.cs b/MangaReader.Core/WebCrawlers/IMangaWebCrawler.cs new file mode 100644 index 0000000..0352795 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/IMangaWebCrawler.cs @@ -0,0 +1,6 @@ +namespace MangaReader.Core.WebCrawlers; + +public interface IMangaWebCrawler +{ + MangaDTO GetManga(string url); +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaChapterDTO.cs b/MangaReader.Core/WebCrawlers/MangaChapterDTO.cs new file mode 100644 index 0000000..ed72876 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaChapterDTO.cs @@ -0,0 +1,11 @@ +namespace MangaReader.Core.WebCrawlers; + +public class MangaChapterDTO +{ + public int? Volume { get; set; } + public float? Number { get; set; } + public string? Name { get; set; } + public required string Url { get; set; } + public long? Views { get; set; } + public DateTime? UploadDate { get; set; } +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaDTO.cs b/MangaReader.Core/WebCrawlers/MangaDTO.cs new file mode 100644 index 0000000..aa51208 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaDTO.cs @@ -0,0 +1,16 @@ +namespace MangaReader.Core.WebCrawlers; + +public class MangaDTO +{ + public required string Title { get; set; } + public string? Description { get; set; } + public List AlternateTitles { get; set; } = []; + public List Authors { get; set; } = []; + public MangaStatus Status { get; set; } = MangaStatus.Unknown; + public List Genres { get; set; } = []; + public DateTime? UpdateDate { get; set; } + public long? Views { get; set; } + public float? RatingPercent { get; set; } + public int? Votes { get; set; } + public List Chapters { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoMangaDocument.cs b/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoMangaDocument.cs new file mode 100644 index 0000000..146843c --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoMangaDocument.cs @@ -0,0 +1,61 @@ +using HtmlAgilityPack; + +namespace MangaReader.Core.WebCrawlers.MangaNato; + +public class MangaNatoMangaDocument +{ + public HtmlNode StoryInfoNode { get; } + public HtmlNode TitleNode { get; } + public HtmlNode StoryInfoRightNode { get; } + public HtmlNode VariationsTableInfo { get; } + public HtmlNodeCollection VariationsTableValueNodes { get; } + public HtmlNode AlternateTitlesNode { get; } + public HtmlNode AuthorsNode { get; } + public HtmlNode StatusNode { get; } + public HtmlNode GenresNode { get; } + public HtmlNode StoryInfoRightExtentNode { get; } + public HtmlNodeCollection StoryInfoRightExtentValueNodes { get; } + public HtmlNode UpdateDateNode { get; } + public HtmlNode ViewsNode { get; } + public HtmlNode ReviewAggregateNode { get; } + public HtmlNode RatingNode { get; } + public HtmlNode AverageRatingNode { get; } + public HtmlNode BestRatingNode { get; } + public HtmlNode VotesNode { get; set; } + public HtmlNode StoryDescriptionNode { get; } + public List StoryDescriptionTextNodes { get; } + public HtmlNode StoryChapterListNode { get; } + public HtmlNodeCollection ChapterNodes { get; } + + public MangaNatoMangaDocument(HtmlDocument document) + { + StoryInfoNode = document.DocumentNode.SelectSingleNode(".//div[@class='panel-story-info']"); + TitleNode = StoryInfoNode.SelectSingleNode(".//h1"); + StoryDescriptionNode = StoryInfoNode.SelectSingleNode(".//div[@class='panel-story-info-description']"); + StoryDescriptionTextNodes = StoryDescriptionNode.ChildNodes.Skip(2).Take(StoryDescriptionNode.ChildNodes.Count - 2).ToList(); + + StoryInfoRightNode = StoryInfoNode.SelectSingleNode(".//div[@class='story-info-right']"); + + VariationsTableInfo = StoryInfoRightNode.SelectSingleNode(".//table[@class='variations-tableInfo']"); + VariationsTableValueNodes = VariationsTableInfo.SelectNodes(".//td[@class='table-value']"); + AlternateTitlesNode = VariationsTableValueNodes[0]; + AuthorsNode = VariationsTableValueNodes[1]; + StatusNode = VariationsTableValueNodes[2]; + GenresNode = VariationsTableValueNodes[3]; + + StoryInfoRightExtentNode = StoryInfoRightNode.SelectSingleNode(".//div[@class='story-info-right-extent']"); + StoryInfoRightExtentValueNodes = StoryInfoRightExtentNode.SelectNodes(".//span[@class='stre-value']"); + UpdateDateNode = StoryInfoRightExtentValueNodes[0]; + ViewsNode = StoryInfoRightExtentValueNodes[1]; + + // v:Review-aggregate + ReviewAggregateNode = StoryInfoRightNode.SelectSingleNode(".//em[@typeof='v:Review-aggregate']"); + RatingNode = ReviewAggregateNode.SelectSingleNode(".//em[@typeof='v:Rating']"); + AverageRatingNode = RatingNode.SelectSingleNode(".//em[@property='v:average']"); + BestRatingNode = RatingNode.SelectSingleNode(".//em[@property='v:best']"); + VotesNode = ReviewAggregateNode.SelectSingleNode(".//em[@property='v:votes']"); + + StoryChapterListNode = document.DocumentNode.SelectSingleNode(".//div[@class='panel-story-chapter-list']"); + ChapterNodes = StoryChapterListNode.SelectNodes(".//li[@class='a-h']"); + } +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoWebCrawler.cs b/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoWebCrawler.cs new file mode 100644 index 0000000..9351d20 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaNato/MangaNatoWebCrawler.cs @@ -0,0 +1,160 @@ +using HtmlAgilityPack; +using System.Text; +using System.Web; + +namespace MangaReader.Core.WebCrawlers.MangaNato; + +public class MangaNatoWebCrawler : MangaWebCrawler +{ + public override MangaDTO GetManga(string url) + { + HtmlDocument document = GetHtmlDocument(url); + MangaNatoMangaDocument node = new(document); + + MangaDTO manga = new() + { + Title = node.TitleNode.InnerText, + AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode), + Authors = GetAuthors(node.AuthorsNode), + Status = GetStatus(node.StatusNode), + Genres = GetGenres(node.GenresNode), + UpdateDate = GetUpdateDate(node.UpdateDateNode), + RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode), + Votes = int.Parse(node.VotesNode.InnerText), + Views = GetViews(node.ViewsNode), + Description = GetTextFromNodes(node.StoryDescriptionTextNodes), + Chapters = GetChapters(node.ChapterNodes) + }; + + return manga; + } + + private static List GetAlternateTitles(HtmlNode node) + { + return node.InnerText.Split(';').Select(x => x.Trim()).ToList(); + } + + private static List GetAuthors(HtmlNode node) + { + return node.InnerText.Split('-').Select(x => x.Trim()).ToList(); + } + + private static MangaStatus GetStatus(HtmlNode node) + { + return node.InnerText switch + { + "Ongoing" => MangaStatus.Ongoing, + "Completed" => MangaStatus.Complete, + _ => MangaStatus.Unknown, + }; + } + + private static List GetGenres(HtmlNode node) + { + return node.InnerText.Split('-').Select(x => x.Trim()).ToList(); + } + + private static DateTime GetUpdateDate(HtmlNode node) + { + List dateAndTime = node.InnerText.Split('-').Select(x => x.Trim()).ToList(); + DateOnly date = DateOnly.Parse(dateAndTime[0]); + TimeOnly time = TimeOnly.Parse(dateAndTime[1]); + + return date.ToDateTime(time); + } + + private static long GetViews(HtmlNode node) + { + string text = node.InnerText; + + if (int.TryParse(text, out int number)) + return number; + + ReadOnlySpan shortText = text.AsSpan(0, text.Length - 1); + + if (double.TryParse(shortText, out double formattedNumber) == false) + return 0; + + char suffix = text[^1]; + long multiplier = GetMultiplier(suffix); + + return (int)(formattedNumber * multiplier); + } + + private static long GetMultiplier(char c) + { + return c switch + { + 'K' => 1_000, + 'M' => 1_000_000, + 'B' => 1_000_000_000, + 'T' => 1_000_000_000_000, + _ => 0, + }; + } + + private static int GetRatingPercent(HtmlNode averageNode, HtmlNode bestNode) + { + double average = Convert.ToDouble(averageNode.InnerText); + double best = Convert.ToDouble(bestNode.InnerText); + + return (int)Math.Round(average / best * 100); + } + + private static List GetChapters(HtmlNodeCollection chapterNodes) + { + List chapters = []; + + foreach (var node in chapterNodes) + { + HtmlNode chapterNameNode = node.SelectSingleNode(".//a[contains(@class, 'chapter-name')]"); + HtmlNode chapterViewNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-view')]"); + HtmlNode chapterTimeNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-time')]"); + + MangaChapterDTO chapter = new() + { + Number = GetChapterNumber(chapterNameNode), + Name = chapterNameNode.InnerText, + Url = chapterNameNode.Attributes["href"].Value, + Views = GetViews(chapterViewNode), + UploadDate = DateTime.Parse(chapterTimeNode.Attributes["title"].Value) + }; + + chapters.Add(chapter); + } + + return chapters; + } + + private static float GetChapterNumber(HtmlNode chapterNameNode) + { + string url = chapterNameNode.Attributes["href"].Value; + int index = url.IndexOf("/chapter-"); + + if (index == -1) + return 0; + + string chapterNumber = url[(index + "/chapter-".Length)..]; + + return float.Parse(chapterNumber); + } + + private static string GetTextFromNodes(List nodes) + { + StringBuilder stringBuilder = new(); + + foreach (HtmlNode node in nodes) + { + if (node.Name == "br") + { + stringBuilder.AppendLine(); + } + else + { + stringBuilder.Append(HttpUtility.HtmlDecode(node.InnerText).Replace("\r\n", "").Trim()); + } + } + + return stringBuilder.ToString(); + } +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaStatus.cs b/MangaReader.Core/WebCrawlers/MangaStatus.cs new file mode 100644 index 0000000..f4781ac --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaStatus.cs @@ -0,0 +1,8 @@ +namespace MangaReader.Core.WebCrawlers; + +public enum MangaStatus +{ + Unknown, + Ongoing, + Complete +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/MangaWebCrawler.cs b/MangaReader.Core/WebCrawlers/MangaWebCrawler.cs new file mode 100644 index 0000000..8fa9d13 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/MangaWebCrawler.cs @@ -0,0 +1,18 @@ +using HtmlAgilityPack; + +namespace MangaReader.Core.WebCrawlers; + +public abstract class MangaWebCrawler : IMangaWebCrawler +{ + public abstract MangaDTO GetManga(string url); + + protected virtual HtmlDocument GetHtmlDocument(string url) + { + HtmlWeb web = new() + { + UsingCacheIfExists = false + }; + + return web.Load(url); + } +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaHtmlDocument.cs b/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaHtmlDocument.cs new file mode 100644 index 0000000..dfdef2f --- /dev/null +++ b/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaHtmlDocument.cs @@ -0,0 +1,21 @@ +using HtmlAgilityPack; + +namespace MangaReader.Core.WebCrawlers.NatoManga; + +public class NatoMangaHtmlDocument +{ + public HtmlNode? MangaInfoTextNode { get; } + public HtmlNode? TitleNode { get; } + public HtmlNode? GenresNode { get; } + public HtmlNode? ChapterListNode { get; } + public HtmlNodeCollection? ChapterNodes { get; } + + public NatoMangaHtmlDocument(HtmlDocument document) + { + MangaInfoTextNode = document.DocumentNode.SelectSingleNode(".//ul[@class='manga-info-text']"); + TitleNode = MangaInfoTextNode?.SelectSingleNode(".//li//h1"); + GenresNode = MangaInfoTextNode?.SelectSingleNode(".//li[@class='genres']"); + ChapterListNode = document.DocumentNode.SelectSingleNode(".//div[@class='chapter-list']"); + ChapterNodes = ChapterListNode?.SelectNodes(".//div[@class='row']"); + } +} \ No newline at end of file diff --git a/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaWebCrawler.cs b/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaWebCrawler.cs new file mode 100644 index 0000000..0f1b484 --- /dev/null +++ b/MangaReader.Core/WebCrawlers/NatoManga/NatoMangaWebCrawler.cs @@ -0,0 +1,186 @@ +using HtmlAgilityPack; +using System.Text; +using System.Web; + +namespace MangaReader.Core.WebCrawlers.NatoManga; + +public class NatoMangaWebCrawler : MangaWebCrawler +{ + public override MangaDTO GetManga(string url) + { + HtmlDocument document = GetHtmlDocument(url); + NatoMangaHtmlDocument node = new(document); + + MangaDTO manga = new() + { + Title = node.TitleNode?.InnerText ?? string.Empty, + //AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode), + //Authors = GetAuthors(node.AuthorsNode), + //Status = GetStatus(node.StatusNode), + Genres = GetGenres(node.GenresNode), + //UpdateDate = GetUpdateDate(node.UpdateDateNode), + //RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode), + //Votes = int.Parse(node.VotesNode.InnerText), + //Views = GetViews(node.ViewsNode), + //Description = GetTextFromNodes(node.StoryDescriptionTextNodes), + Chapters = GetChapters(node.ChapterNodes) + }; + + return manga; + } + + private static List GetAlternateTitles(HtmlNode node) + { + return node.InnerText.Split(';').Select(x => x.Trim()).ToList(); + } + + private static List GetAuthors(HtmlNode node) + { + return node.InnerText.Split('-').Select(x => x.Trim()).ToList(); + } + + private static MangaStatus GetStatus(HtmlNode node) + { + return node.InnerText switch + { + "Ongoing" => MangaStatus.Ongoing, + "Completed" => MangaStatus.Complete, + _ => MangaStatus.Unknown, + }; + } + + private static List GetGenres(HtmlNode? node) + { + if (node == null) + return []; + + HtmlNodeCollection genreNodes = node.SelectNodes(".//a"); + + if (genreNodes == null) + return []; + + return [.. genreNodes.Select(genreNode => genreNode.InnerText.Trim())]; + } + + private static DateTime GetUpdateDate(HtmlNode node) + { + List dateAndTime = node.InnerText.Split('-').Select(x => x.Trim()).ToList(); + DateOnly date = DateOnly.Parse(dateAndTime[0]); + TimeOnly time = TimeOnly.Parse(dateAndTime[1]); + + return date.ToDateTime(time); + } + + private static long GetViews(HtmlNode node) + { + string text = node.InnerText.Trim(); + + if (int.TryParse(text, out int number)) + return number; + + if (double.TryParse(text, out double doubleNumber)) + return (int)doubleNumber; + + ReadOnlySpan shortText = text.AsSpan(0, text.Length - 1); + + if (double.TryParse(shortText, out double formattedNumber) == false) + return 0; + + char suffix = text[^1]; + + //if (char.GetNumericValue(suffix) > -1) + // return (int)formattedNumber; + + long multiplier = GetMultiplier(suffix); + + return (int)(formattedNumber * multiplier); + } + + private static long GetMultiplier(char c) + { + return c switch + { + 'K' => 1_000, + 'M' => 1_000_000, + 'B' => 1_000_000_000, + 'T' => 1_000_000_000_000, + _ => 0, + }; + } + + private static int GetRatingPercent(HtmlNode averageNode, HtmlNode bestNode) + { + double average = Convert.ToDouble(averageNode.InnerText); + double best = Convert.ToDouble(bestNode.InnerText); + + return (int)Math.Round(average / best * 100); + } + + private static List GetChapters(HtmlNodeCollection? chapterNodes) + { + List chapters = []; + + if (chapterNodes == null) + return chapters; + + foreach (var node in chapterNodes) + { + HtmlNodeCollection? chapterPropertyNodes = node.SelectNodes(".//span"); + + if (chapterPropertyNodes == null || chapterPropertyNodes.Count < 3) + continue; + + HtmlNode? chapterNameNode = chapterPropertyNodes[0].SelectSingleNode(".//a"); + HtmlNode chapterViewNode = chapterPropertyNodes[1]; + HtmlNode chapterTimeNode = chapterPropertyNodes[2]; + + if (chapterNameNode == null) + continue; + + MangaChapterDTO chapter = new() + { + Number = GetChapterNumber(chapterNameNode), + Name = chapterNameNode.InnerText, + Url = chapterNameNode.Attributes["href"].Value, + Views = GetViews(chapterViewNode), + UploadDate = DateTime.Parse(chapterTimeNode.Attributes["title"].Value) + }; + + chapters.Add(chapter); + } + + return chapters; + } + + private static float GetChapterNumber(HtmlNode chapterNameNode) + { + string url = chapterNameNode.Attributes["href"].Value; + int index = url.IndexOf("/chapter-"); + + if (index == -1) + return 0; + + string chapterNumber = url[(index + "/chapter-".Length)..].Replace('-', '.'); + + return float.Parse(chapterNumber); + } + + private static string GetTextFromNodes(List nodes) + { + StringBuilder stringBuilder = new(); + + foreach (HtmlNode node in nodes) + { + if (node.Name == "br") + { + stringBuilder.AppendLine(); + } + else + { + stringBuilder.Append(HttpUtility.HtmlDecode(node.InnerText).Replace("\r\n", "").Trim()); + } + } + + return stringBuilder.ToString(); + } +} \ No newline at end of file diff --git a/MangaReader.Core/WebSearch/IMangaWebSearch.cs b/MangaReader.Core/WebSearch/IMangaWebSearch.cs new file mode 100644 index 0000000..446f368 --- /dev/null +++ b/MangaReader.Core/WebSearch/IMangaWebSearch.cs @@ -0,0 +1,11 @@ +namespace MangaReader.Core.WebSearch; + +public interface IMangaWebSearch +{ + Task SearchAsync(string keyword); +} + +//public class MangaDexWebSearch : IMangaWebSearch +//{ +// // https://api.mangadex.org/manga?title=gal can't be&limit=5 +//} \ No newline at end of file diff --git a/MangaReader.Core/WebSearch/MangaSearchResult.cs b/MangaReader.Core/WebSearch/MangaSearchResult.cs new file mode 100644 index 0000000..e144c4c --- /dev/null +++ b/MangaReader.Core/WebSearch/MangaSearchResult.cs @@ -0,0 +1,9 @@ +namespace MangaReader.Core.WebSearch; + +public record MangaSearchResult +{ + public required string Url { get; init; } + public required string Title { get; init; } + public string? Author { get; init; } + public string? Description { get; init; } +} \ No newline at end of file diff --git a/MangaReader.Core/WebSearch/MangaWebSearchBase.cs b/MangaReader.Core/WebSearch/MangaWebSearchBase.cs new file mode 100644 index 0000000..ce4f739 --- /dev/null +++ b/MangaReader.Core/WebSearch/MangaWebSearchBase.cs @@ -0,0 +1,33 @@ +using MangaReader.Core.HttpService; +using System.Text.Json; + +namespace MangaReader.Core.WebSearch; + +public abstract class MangaWebSearchBase(IHttpService httpService) : IMangaWebSearch +{ + private static JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public async Task SearchAsync(string keyword) + { + T? searchResult = await GetSearchResultAsync(keyword); + + if (searchResult == null) + return []; + + return GetSearchResult(searchResult); + } + + private async Task GetSearchResultAsync(string keyword) + { + string url = GetSearchUrl(keyword); + string response = await httpService.GetStringAsync(url); + + return JsonSerializer.Deserialize(response, _jsonSerializerOptions); + } + + protected abstract string GetSearchUrl(string keyword); + protected abstract MangaSearchResult[] GetSearchResult(T searchResult); +} \ No newline at end of file diff --git a/MangaReader.Core/WebSearch/NatoManga/NatoMangaSearchResult.cs b/MangaReader.Core/WebSearch/NatoManga/NatoMangaSearchResult.cs new file mode 100644 index 0000000..91feddf --- /dev/null +++ b/MangaReader.Core/WebSearch/NatoManga/NatoMangaSearchResult.cs @@ -0,0 +1,12 @@ +namespace MangaReader.Core.WebSearch.NatoManga; + +public record NatoMangaSearchResult +{ + public int Id { get; init; } + public string? Author { get; init; } + public required string Name { get; init; } + public string? ChapterLatest { get; init; } + public required string Url { get; init; } + public string? Thumb { get; init; } + public string? Slug { get; init; } +} \ No newline at end of file diff --git a/MangaReader.Core/WebSearch/NatoManga/NatoMangaWebSearch.cs b/MangaReader.Core/WebSearch/NatoManga/NatoMangaWebSearch.cs new file mode 100644 index 0000000..2e14834 --- /dev/null +++ b/MangaReader.Core/WebSearch/NatoManga/NatoMangaWebSearch.cs @@ -0,0 +1,25 @@ +using MangaReader.Core.HttpService; + +namespace MangaReader.Core.WebSearch.NatoManga; + +public class NatoMangaWebSearch(IHttpService httpService) : MangaWebSearchBase(httpService) +{ + // https://www.natomanga.com/home/search/json?searchword=gal_can_t_be_kind + + protected override string GetSearchUrl(string keyword) + { + return $"https://www.natomanga.com/home/search/json?searchword={keyword}"; + } + + protected override MangaSearchResult[] GetSearchResult(NatoMangaSearchResult[] searchResult) + { + IEnumerable mangaSearchResults = searchResult.Select(searchResult => + new MangaSearchResult() + { + Title = searchResult.Name, + Url = searchResult.Url + }); + + return [.. mangaSearchResults]; + } +} \ No newline at end of file diff --git a/MangaReader.Tests/MangaReader.Tests.csproj b/MangaReader.Tests/MangaReader.Tests.csproj new file mode 100644 index 0000000..c5a9f88 --- /dev/null +++ b/MangaReader.Tests/MangaReader.Tests.csproj @@ -0,0 +1,53 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/MangaReader.Tests/Utilities/ResourceHelper.cs b/MangaReader.Tests/Utilities/ResourceHelper.cs new file mode 100644 index 0000000..eba67f6 --- /dev/null +++ b/MangaReader.Tests/Utilities/ResourceHelper.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Text; + +namespace MangaReader.Tests.Utilities; + +public static class ResourceHelper +{ + /// + /// Reads an embedded JSON resource from the calling assembly. + /// + /// The full resource name, e.g. "MyNamespace.Folder.sample.json". + public static async Task ReadJsonResourceAsync(string resourceName) + { + Assembly assmbly = Assembly.GetExecutingAssembly(); + + using Stream? stream = assmbly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Resource '{resourceName}' not found."); + + using StreamReader reader = new(stream, Encoding.UTF8); + + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/WebCrawlers/NatoManga/NatoMangaWebCrawlerTests.cs b/MangaReader.Tests/WebCrawlers/NatoManga/NatoMangaWebCrawlerTests.cs new file mode 100644 index 0000000..8ab85cc --- /dev/null +++ b/MangaReader.Tests/WebCrawlers/NatoManga/NatoMangaWebCrawlerTests.cs @@ -0,0 +1,52 @@ +using MangaReader.Core.WebCrawlers.NatoManga; +using Shouldly; + +namespace MangaReader.Tests.WebCrawlers.NatoManga; + +public class NatoMangaWebCrawlerTests +{ + [Fact] + public void Get_Manga() + { + string sampleFilePath = Path.Combine(AppContext.BaseDirectory, "WebCrawlers", "NatoManga", "SampleMangaPage.html"); + + var webCrawler = new NatoMangaWebCrawler(); + var manga = webCrawler.GetManga(sampleFilePath); + + manga.ShouldNotBeNull(); + + manga.Title.ShouldBe("Gal Can’t Be Kind to Otaku!?"); + + //manga.AlternateTitles.ShouldBe([ + // "Kaette kudasai! Akutsu-san", + // "Yankee Musume", + // "ヤンキー娘", + // "帰ってください! 阿久津さん"]); + + //manga.Authors.ShouldBe(["Nagaoka Taichi"]); + //manga.Status.ShouldBe(MangaStatus.Ongoing); + manga.Genres.ShouldBe(["Comedy", "Harem", "Romance", "School life", "Seinen", "Slice of life"]); + //manga.UpdateDate.ShouldBe(new DateTime(2024, 9, 26, 0, 12, 0)); + //manga.Views.ShouldBe(93_300_000); + //manga.RatingPercent.ShouldBe(97); + //manga.Votes.ShouldBe(15979); + + ////manga.Description.ShouldStartWith("Ooyama-kun normally doesn’t get involved with Akutsu-san, a delinquent girl in his class"); + //manga.Description.ShouldStartWith("Ooyama-kun normally doesn’t get involved with Akutsu-san, a delinquent girl in his class"); + //manga.Description.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935"); + + manga.Chapters.Count.ShouldBe(83); + + manga.Chapters[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku/chapter-69"); + manga.Chapters[0].Number.ShouldBe(69); + manga.Chapters[0].Name.ShouldBe("Chapter 69"); + manga.Chapters[0].Views.ShouldBe(8146); + //manga.Chapters[0].UploadDate.ShouldBe(new DateTime(2025, 4, 23, 17, 17, 0)); + + //manga.Chapters[235].URL.ShouldBe("https://chapmanganato.to/manga-hf984788/chapter-0.1"); + //manga.Chapters[235].Number.ShouldBe(0.1f); + //manga.Chapters[235].Name.ShouldBe("Vol.0 Chapter : Oneshot"); + //manga.Chapters[235].Views.ShouldBe(232_200); + //manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0)); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/WebCrawlers/NatoManga/SampleMangaPage.html b/MangaReader.Tests/WebCrawlers/NatoManga/SampleMangaPage.html new file mode 100644 index 0000000..0340c4d --- /dev/null +++ b/MangaReader.Tests/WebCrawlers/NatoManga/SampleMangaPage.html @@ -0,0 +1,3112 @@ + + + + + + + + Read Gal Can’t Be Kind to Otaku!? Manga Online - Latest Chapters & Free Scans | MangaNato + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ + +
+
+ + + +
+
+
+
MENU
+ +
+
+ + + + +
+ +
+
+ + + + +
+ + +
+
+ Gal Can’t Be Kind to Otaku!? +
+
+ +
+

Status:

+

Completed

+
+
+

Last updated:

+

Apr-23-2025 05:17:44 PM

+
+
+

View:

+

2,034,380

+
+
+

Rating:

+

+ + + 5 / 5 - 52 votes + +

+
+
+
+
+
+ +
+ CHAPTER LIST + +
+ + +
+

+ 📢 New Feature: Save Reading History for Guests!
+ MangaNato automatically saves your reading history—no account required!
+ Pick up your favorite manga right where you left off with ease. Log in to keep your progress synced across devices.
+ 📌 Find your saved history in the section on the site. +

+
+
+ +
+
+ Gal Can’t Be Kind to Otaku!? + CHAPTER LIST + +
+
+
    +
  • +

    Gal Can’t Be Kind to Otaku!?

    +
  • + +
  • + Author(s) : + + Norishiro-chan + , + + Sakana Uozimi + +
  • +
  • Status : Completed
  • +
  • Last updated : Apr-23-2025 05:17:44 PM
  • +
  • TransGroup :
  • +
  • View : 2,034,380
  • + +
  • + Genres : + + Comedy + , + + Harem + , + + Romance + , + + School life + , + + Seinen + , + + Slice of life + +
  • +
  • + Rating : + + +
  • +
  • +   + + natomanga.com rate : 5 / 5 - 52 votes + + +
  • +
  • + +
+

+ 📢 New Feature: Save Reading History for Guests!
+ MangaNato automatically saves your reading history—no account required!
+ Pick up your favorite manga right where you left off with ease. Log in to keep your progress synced across devices.
+ 📌 Find your saved history in the section on the site. +

+
+ +
+ + + + + +
+
+
+ Chapter name + View + Time uploaded +
+
+
+ + Chapter 69 + + 8,146 + 04-23 17:17 +
+
+ + Chapter 68 + + 5,443 + 04-23 17:17 +
+
+ + Chapter 67 + + 5,940 + 04-23 17:17 +
+
+ + Chapter 66 + + 7,744 + 04-20 20:11 +
+
+ + Chapter 65 + + 8,573 + 04-19 21:21 +
+
+ + Chapter 64 + + 7,833 + 04-19 20:43 +
+
+ + Chapter 63 + + 7,426 + 04-19 17:40 +
+
+ + Chapter 62 + + 6,479 + 04-19 17:40 +
+
+ + Chapter 61 + + 9,169 + 04-19 03:50 +
+
+ + Chapter 60 + + 8,080 + 04-18 18:00 +
+
+ + Chapter 59 + + 7,704 + 04-18 18:00 +
+
+ + Chapter 58 + + 7,310 + 04-18 18:00 +
+
+ + Chapter 57 + + 10,654 + 04-03 05:03 +
+
+ + Chapter 56 + + 8,628 + 04-03 00:05 +
+
+ + Chapter 55 + + 1,043 + 03-03 02:38 +
+
+ + Chapter 54 + + 866 + 03-03 02:35 +
+
+ + Chapter 53 + + 2,956 + 02-25 11:33 +
+
+ + Chapter 52 + + 2,958 + 02-25 00:49 +
+
+ + Chapter 51 + + 2,879 + 02-25 00:49 +
+
+ + Chapter 50 + + 2,383 + 02-16 00:33 +
+
+ + Chapter 49 + + 1,373 + 02-14 19:30 +
+
+ + Chapter 48 + + 1,367 + 02-14 19:30 +
+
+ + Chapter 47.1 + + 412 + 02-06 11:54 +
+
+ + Chapter 47 + + 1,077 + 02-05 17:58 +
+
+ + Chapter 46 + + 1,071 + 02-02 13:34 +
+
+ + Chapter 45 + + 1,045 + 02-02 13:33 +
+
+ + Chapter 44 + + 983 + 02-02 13:33 +
+
+ + Chapter 43 + + 931 + 02-02 13:33 +
+
+ + Chapter 42 + + 1,023 + 02-02 13:32 +
+
+ + Chapter 41 + + 888 + 01-16 12:31 +
+
+ + Chapter 40 + + 815 + 01-07 19:31 +
+
+ + Chapter 39 + + 727 + 01-05 09:36 +
+
+ + Chapter 38 + + 902 + 01-03 03:31 +
+
+ + Chapter 37 + + 701 + 01-02 08:46 +
+
+ + Chapter 36 + + 669 + 01-02 08:03 +
+
+ + Chapter 35 + + 663 + 12-04 16:38 +
+
+ + Chapter 34 + + 674 + 12-04 11:30 +
+
+ + Chapter 33 + + 784 + 11-08 04:36 +
+
+ + Chapter 32 + + 19,332 + 11-01 05:13 +
+
+ + Chapter 31 + + 15,655 + 11-01 05:12 +
+
+ + Chapter 30 + + 32,513 + 11-01 05:12 +
+
+ + Chapter 29 + + 24,428 + 11-01 05:12 +
+
+ + Chapter 28 + + 21,942 + 11-01 05:11 +
+
+ + Chapter 27 + + 23,629 + 11-01 05:11 +
+
+ + Chapter 26 + + 21,881 + 11-01 05:11 +
+
+ + Chapter 25 + + 24,863 + 11-01 05:10 +
+
+ + Chapter 24 + + 25,925 + 11-01 05:10 +
+
+ + Chapter 23 + + 24,863 + 11-01 05:10 +
+
+ + Chapter 22 + + 28,201 + 11-01 05:09 +
+
+ + Chapter 21 + + 28,266 + 11-01 05:09 +
+
+ + Chapter 20 + + 29,645 + 11-01 05:09 +
+
+ + Chapter 19 + + 30,569 + 11-01 05:08 +
+
+ + Chapter 18 + + 29,901 + 11-01 05:08 +
+
+ + Chapter 17 + + 31,041 + 11-01 05:08 +
+
+ + Chapter 16 + + 34,052 + 11-01 05:07 +
+
+ + Chapter 15 + + 36,746 + 11-01 05:07 +
+
+ + Chapter 14 + + 37,853 + 11-01 05:07 +
+
+ + Chapter 13 + + 49,024 + 11-01 05:07 +
+
+ + Chapter 12 + + 52,024 + 11-01 05:06 +
+
+ + Chapter 11.1 + + 43,901 + 11-01 05:05 +
+
+ + Chapter 11 + + 30,559 + 11-01 05:05 +
+
+ + Chapter 10.6 + + 52,361 + 11-01 05:04 +
+
+ + Chapter 10.5 + + 45,846 + 11-01 05:04 +
+
+ + Chapter 10.4 + + 45,018 + 11-01 05:04 +
+
+ + Chapter 10.3 + + 44,925 + 11-01 05:04 +
+
+ + Chapter 10.2 + + 45,288 + 11-01 05:04 +
+
+ + Chapter 10.1 + + 47,530 + 11-01 05:03 +
+
+ + Chapter 9.4 + + 47,203 + 11-01 05:03 +
+
+ + Chapter 9.3 + + 52,753 + 11-01 05:03 +
+
+ + Chapter 9.2 + + 51,993 + 11-01 05:03 +
+
+ + Chapter 9.1 + + 48,161 + 11-01 05:03 +
+
+ + Chapter 8.2 + + 49,657 + 11-01 05:02 +
+
+ + Chapter 8.1 + + 55,342 + 11-01 05:02 +
+
+ + Chapter 8 + + 30,501 + 11-01 05:02 +
+
+ + Chapter 7 + + 59,859 + 11-01 05:02 +
+
+ + Chapter 6 + + 69,944 + 11-01 05:01 +
+
+ + Chapter 5 + + 67,370 + 11-01 05:01 +
+
+ + Chapter 4 + + 65,512 + 11-01 05:00 +
+
+ + Chapter 3 + + 71,960 + 11-01 04:59 +
+
+ + Chapter 2 + + 80,434 + 11-01 04:59 +
+
+ + Chapter 1.2 + + 15,368 + 11-01 04:58 +
+
+ + Chapter 1.1 + + 23,241 + 11-01 04:58 +
+
+ + Chapter 1 + + 128,982 + 11-01 04:58 +
+
+
+
+ + + +
+

+

Gal Can’t Be Kind to Otaku!? summary:

+

+ You are reading Gal Can’t Be Kind to Otaku!? manga, one of the most popular manga covering in Comedy, Harem, Romance, School life, Seinen, Slice of life genres, written by Norishiro-chan, Sakana Uozimi at MangaBuddy, a top manga site to offering for free. Gal Can’t Be Kind to Otaku!? has 83 translated chapters and translations of other chapters are in progress. Lets enjoy. If you want to get the updates about latest chapters, lets create an account and add Gal Can’t Be Kind to Otaku!? to your bookmark. + + Takuya Seo is an otaku who likes "anime for girls" and can't say he likes it out loud. One day, he hooks up with two gals from his class, Amane and Ijichi, but it seems that Amane is also an otaku... It's about a school romantic comedy where the otaku meets the gals he like! +
+ +
+ +
+ + +
+ + +
+
+
+
+
+ +
+ + +
+

GENRES

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Newest + + Latest + + Top read +
+ All + + Completed + + Ongoing +
+ Comedy + + Supernatural + + Drama +
+ Fantasy + + Action + + Josei +
+ Adventure + + Romance + + Smut +
+ Manhwa + + Tragedy + + Slice of life +
+ School life + + Seinen + + Historical +
+ Harem + + Horror + + Psychological +
+ Mystery + + Shounen + + Martial arts +
+ Manhua + + Shoujo + + Isekai +
+ Erotica + + Gender bender + + Mature +
+ Webtoons + + Shoujo ai + + Yaoi +
+ Yuri + + Medical + + Mecha +
+ Shounen ai + + Sports + + Cooking +
+ Sci fi + + One shot + + Ecchi +
+ Adult + + Pornographic + + Doujinshi +
+ Long Strip + + Survival + + Adaptation +
+ Official Colored + + Wuxia + + Thriller +
+ Web Comic + + Full Color + + Reincarnation +
+ Monsters + + Military + + Philosophical +
+ Gyaru + + Bloody + + Demons +
+ System + + Loli + + Ninja +
+ Incest + + Crime + + Office Workers +
+ Sexual Violence + + Crossdressing + + Gore +
+ Delinquents + + Shota + + Police +
+ Manga + + Time Travel + + Monster Girls +
+ Anthology + + 4-Koma + + Oneshot +
+ Animals + + Heartwarming + + Superhero +
+ Magic + + Genderswap + + Post-Apocalyptic +
+ Music + + Sci-Fi + + Self-Published +
+ Aliens + + Villainess + + Virtual Reality +
+ Ghosts + + Award Winning + + Video Games +
+ Magical Girls + + Reverse Harem + + Fan Colored +
+ Zombies + + Mafia + + Webtoon +
+ Royal family + + Manhwa Hot + + Traditional Games +
+ Magical + + Vampires + + Revenge +
+ ecchi 2 + + Post apocalyptic + + Samurai +
+ Yaoi(BL) + + Monster + + Super Power +
+ Animal + + Game + + Comic +
+ Science fiction + + Office + + School +
+ Parody + + Iyashikei + + Girls Love +
+ Boys Love + + Mahou Shoujo + + Suspense +
+ Vampire + + Kids + + Space +
+ Gourmet + + Soft Yaoi + + Avant Garde +
+ slice_of_life + + full_color + + school_life +
+ martial_arts + + shounen_ai + + time_travel +
+ cartoon + + violence + + magical_girls +
+ monster_girls + + gender_bender + + imageset +
+ teacher_student + + cultivation + + super_power +
+ death_game + + degeneratemc + + reverse_harem +
+ cars + + office_workers + + _4_koma +
+ video_games + + virtual_reality + + sci_fi +
+ showbiz + + post_apocalyptic + + blackmail +
+ western + + shoujo_ai + + xianxia +
+ fetish + + netorare + + age_gap +
+ ai_art + + master_servant + + college_life +
+ childhood_friends + + non_human + + traditional_games +
+ dementia + + Informative + + Graphic Novel +
+
+ + + +
+
+
+
+ + + +
+ +
+ +
+ + + +
+ +
+

content notification

+
CANCEL
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/MangaReader.Tests/WebCrawlers/Samples/MangaNato - Please Go Home, Akutsu-San!.htm b/MangaReader.Tests/WebCrawlers/Samples/MangaNato - Please Go Home, Akutsu-San!.htm new file mode 100644 index 0000000..27dd410 --- /dev/null +++ b/MangaReader.Tests/WebCrawlers/Samples/MangaNato - Please Go Home, Akutsu-San!.htm @@ -0,0 +1,1533 @@ + + + + + + + + + +Please Go Home, Akutsu-San! Manga Online Free - Manganato + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ + +
+ + Register + +
+
+ MENU + +

TOP WEEK

+
+
+ + + + + + + + + +
+

+ Chapter name + View + Uploaded +

+ +
+ +
+

Facebook Comments

+
+
+ +
+ +
+ + + + + + + + + + +
+ +
+

content notification

+
CANCEL
+
+
+ +
+
+ + + +
Back to Top
\ No newline at end of file diff --git a/MangaReader.Tests/WebCrawlers/UnitTest1.cs b/MangaReader.Tests/WebCrawlers/UnitTest1.cs new file mode 100644 index 0000000..1512e39 --- /dev/null +++ b/MangaReader.Tests/WebCrawlers/UnitTest1.cs @@ -0,0 +1,60 @@ +using MangaReader.Core.WebCrawlers; +using MangaReader.Core.WebCrawlers.MangaNato; +using Shouldly; + +namespace MangaReader.Tests.WebCrawlers; + +public class UnitTest1 +{ + private readonly string samplesPath; + private readonly string mangaNatoSampleFilePath; + + public UnitTest1() + { + samplesPath = Path.Combine(AppContext.BaseDirectory, "WebCrawlers", "Samples"); + mangaNatoSampleFilePath = Path.Combine(samplesPath, "MangaNato - Please Go Home, Akutsu-San!.htm"); + } + + [Fact] + public void Get_Manga() + { + var webCrawler = new MangaNatoWebCrawler(); + var manga = webCrawler.GetManga(mangaNatoSampleFilePath); + + manga.ShouldNotBeNull(); + + manga.Title.ShouldBe("Please Go Home, Akutsu-San!"); + + manga.AlternateTitles.ShouldBe([ + "Kaette kudasai! Akutsu-san", + "Yankee Musume", + "ヤンキー娘", + "帰ってください! 阿久津さん"]); + + manga.Authors.ShouldBe(["Nagaoka Taichi"]); + manga.Status.ShouldBe(MangaStatus.Ongoing); + manga.Genres.ShouldBe(["Comedy", "Romance", "School life"]); + manga.UpdateDate.ShouldBe(new DateTime(2024, 9, 26, 0, 12, 0)); + manga.Views.ShouldBe(93_300_000); + manga.RatingPercent.ShouldBe(97); + manga.Votes.ShouldBe(15979); + + //manga.Description.ShouldStartWith("Ooyama-kun normally doesn’t get involved with Akutsu-san, a delinquent girl in his class"); + manga.Description.ShouldStartWith("Ooyama-kun normally doesn’t get involved with Akutsu-san, a delinquent girl in his class"); + manga.Description.ShouldEndWith("Artist's Pixiv: https://www.pixiv.net/member.php?id=133935"); + + manga.Chapters.Count.ShouldBe(236); + + manga.Chapters[0].Url.ShouldBe("https://chapmanganato.to/manga-hf984788/chapter-186"); + manga.Chapters[0].Number.ShouldBe(186); + manga.Chapters[0].Name.ShouldBe("Chapter 186"); + manga.Chapters[0].Views.ShouldBe(37_900); + manga.Chapters[0].UploadDate.ShouldBe(new DateTime(2024, 9, 26, 0, 9, 0)); + + manga.Chapters[235].Url.ShouldBe("https://chapmanganato.to/manga-hf984788/chapter-0.1"); + manga.Chapters[235].Number.ShouldBe(0.1f); + manga.Chapters[235].Name.ShouldBe("Vol.0 Chapter : Oneshot"); + manga.Chapters[235].Views.ShouldBe(232_200); + manga.Chapters[235].UploadDate.ShouldBe(new DateTime(2021, 8, 24, 1, 8, 0)); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs b/MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs new file mode 100644 index 0000000..8a4802e --- /dev/null +++ b/MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs @@ -0,0 +1,32 @@ +using MangaReader.Core.HttpService; +using MangaReader.Core.WebSearch; +using MangaReader.Core.WebSearch.NatoManga; +using MangaReader.Tests.Utilities; +using NSubstitute; +using Shouldly; + +namespace MangaReader.Tests.WebSearch.NatoManga; + +public class NatoMangaWebSearchTests +{ + [Fact] + public async Task Get_Search_Result() + { + string resourceName = "MangaReader.Tests.WebSearch.NatoManga.SampleSearchResult.json"; + string searchResultJson = await ResourceHelper.ReadJsonResourceAsync(resourceName); + + IHttpService httpService = Substitute.For(); + + httpService.GetStringAsync(Arg.Any()) + .Returns(Task.FromResult(searchResultJson)); + + NatoMangaWebSearch webSearch = new(httpService); + MangaSearchResult[] searchResult = await webSearch.SearchAsync("Gals Can't Be Kind"); + + searchResult.Length.ShouldBe(2); + searchResult[0].Title.ShouldBe("Gal Can't Be Kind to Otaku!"); + searchResult[1].Title.ShouldBe("Gal Can’t Be Kind to Otaku!?"); + searchResult[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-can-t-be-kind-to-otaku"); + searchResult[1].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku"); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json b/MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json new file mode 100644 index 0000000..e9ad052 --- /dev/null +++ b/MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json @@ -0,0 +1,20 @@ +[ + { + "id": 51811, + "author": "Norishiro-chan, Sakana Uozumi", + "name": "Gal Can't Be Kind to Otaku!", + "chapterLatest": "Chapter 69", + "url": "https:\/\/www.natomanga.com\/manga\/gal-can-t-be-kind-to-otaku", + "thumb": "https:\/\/img-r1.2xstorage.com\/thumb\/gal-can-t-be-kind-to-otaku.webp", + "slug": "gal-can-t-be-kind-to-otaku" + }, + { + "id": 38065, + "author": "Norishiro-chan,Sakana Uozimi", + "name": "Gal Can\u2019t Be Kind to Otaku!?", + "chapterLatest": "Chapter 69", + "url": "https:\/\/www.natomanga.com\/manga\/gal-cant-be-kind-to-otaku", + "thumb": "https:\/\/img-r1.2xstorage.com\/thumb\/gal-cant-be-kind-to-otaku.webp", + "slug": "gal-cant-be-kind-to-otaku" + } +] \ No newline at end of file diff --git a/MangaReader.sln b/MangaReader.sln new file mode 100644 index 0000000..65572d6 --- /dev/null +++ b/MangaReader.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35303.130 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MangaReader.Core", "MangaReader.Core\MangaReader.Core.csproj", "{D1C7616F-8794-4F52-9694-6814BD368F0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MangaReader.Tests", "MangaReader.Tests\MangaReader.Tests.csproj", "{D86F1282-485A-4FF2-A75A-AB8102F3C853}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D1C7616F-8794-4F52-9694-6814BD368F0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1C7616F-8794-4F52-9694-6814BD368F0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1C7616F-8794-4F52-9694-6814BD368F0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1C7616F-8794-4F52-9694-6814BD368F0B}.Release|Any CPU.Build.0 = Release|Any CPU + {D86F1282-485A-4FF2-A75A-AB8102F3C853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D86F1282-485A-4FF2-A75A-AB8102F3C853}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D86F1282-485A-4FF2-A75A-AB8102F3C853}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D86F1282-485A-4FF2-A75A-AB8102F3C853}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6E11B5CA-67EA-463B-B6AE-1D2065A04F76} + EndGlobalSection +EndGlobal