From 1a752bb57e74ed609388da257d55533c71d15ef8 Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Sat, 24 May 2025 15:56:44 -0400 Subject: [PATCH] Added contributor classes for manga. Implemented MangaDex search. --- MangaReader.Core/Data/Contributor.cs | 9 + MangaReader.Core/Data/Manga.cs | 20 +- MangaReader.Core/Data/MangaContext.cs | 34 ++ MangaReader.Core/Data/MangaContributor.cs | 12 + MangaReader.Core/Data/MangaContributorRole.cs | 7 + MangaReader.Core/MangaReader.Core.csproj | 2 +- .../Search/MangaDex/MangaDexSearchProvider.cs | 66 +++ .../Search/MangaDex/MangaDexSearchResult.cs | 8 + .../MangaDex/MangaDexSearchResultData.cs | 8 + .../MangaDexSearchResultDataAttributes.cs | 8 + .../MangaDexSearchResultDataRelationship.cs | 8 + .../Search/MangaSearchProviderBase.cs | 2 +- MangaReader.Core/Search/MangaSearchResult.cs | 1 + .../NatoManga/NatoMangaSearchProvider.cs | 37 +- MangaReader.Core/WebCrawlers/SourceManga.cs | 1 + MangaReader.Tests/MangaReader.Tests.csproj | 6 +- .../Search/MangaDex/MangaDexSearchTests.cs | 50 ++ .../Search/MangaDex/SampleSearchResult.json | 426 ++++++++++++++++++ .../NatoManga/NatoMangaWebSearchTests.cs | 25 +- .../NatoManga/SampleSearchResult.json | 0 20 files changed, 706 insertions(+), 24 deletions(-) create mode 100644 MangaReader.Core/Data/Contributor.cs create mode 100644 MangaReader.Core/Data/MangaContributor.cs create mode 100644 MangaReader.Core/Data/MangaContributorRole.cs create mode 100644 MangaReader.Core/Search/MangaDex/MangaDexSearchProvider.cs create mode 100644 MangaReader.Core/Search/MangaDex/MangaDexSearchResult.cs create mode 100644 MangaReader.Core/Search/MangaDex/MangaDexSearchResultData.cs create mode 100644 MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataAttributes.cs create mode 100644 MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataRelationship.cs create mode 100644 MangaReader.Tests/Search/MangaDex/MangaDexSearchTests.cs create mode 100644 MangaReader.Tests/Search/MangaDex/SampleSearchResult.json rename MangaReader.Tests/{WebSearch => Search}/NatoManga/NatoMangaWebSearchTests.cs (57%) rename MangaReader.Tests/{WebSearch => Search}/NatoManga/SampleSearchResult.json (100%) diff --git a/MangaReader.Core/Data/Contributor.cs b/MangaReader.Core/Data/Contributor.cs new file mode 100644 index 0000000..f2bcc26 --- /dev/null +++ b/MangaReader.Core/Data/Contributor.cs @@ -0,0 +1,9 @@ +namespace MangaReader.Core.Data; + +public class Contributor +{ + public int ContributorId { get; set; } + public required string Name { get; set; } + + public ICollection MangaContributions { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/Data/Manga.cs b/MangaReader.Core/Data/Manga.cs index 0245cff..147c9c2 100644 --- a/MangaReader.Core/Data/Manga.cs +++ b/MangaReader.Core/Data/Manga.cs @@ -7,18 +7,10 @@ public class Manga 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 virtual ICollection Covers { get; set; } = []; + public virtual ICollection Titles { get; set; } = []; + public virtual ICollection Sources { get; set; } = []; + public virtual ICollection Contributors { get; set; } = []; + public virtual ICollection Genres { get; set; } = []; + public virtual ICollection Chapters { get; set; } = []; } \ No newline at end of file diff --git a/MangaReader.Core/Data/MangaContext.cs b/MangaReader.Core/Data/MangaContext.cs index 45c5562..fdc7dce 100644 --- a/MangaReader.Core/Data/MangaContext.cs +++ b/MangaReader.Core/Data/MangaContext.cs @@ -9,6 +9,8 @@ public class MangaContext(DbContextOptions options) : DbContext(options) public DbSet MangaTitles { get; set; } public DbSet Sources { get; set; } public DbSet MangaSources { get; set; } + public DbSet Contributors { get; set; } + public DbSet MangaContributors { get; set; } public DbSet Genres { get; set; } public DbSet MangaGenres { get; set; } public DbSet MangaChapters { get; set; } @@ -24,6 +26,8 @@ public class MangaContext(DbContextOptions options) : DbContext(options) ConfigureMangaTitle(modelBuilder); ConfigureSource(modelBuilder); ConfigureMangaSource(modelBuilder); + ConfigureContributor(modelBuilder); + ConfigureMangaContributor(modelBuilder); ConfigureGenre(modelBuilder); ConfigureMangaGenre(modelBuilder); ConfigureMangaChapter(modelBuilder); @@ -37,6 +41,10 @@ public class MangaContext(DbContextOptions options) : DbContext(options) .Entity() .HasKey(x => x.MangaId); + modelBuilder.Entity() + .HasIndex(x => x.Title) + .IsUnique(); + modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); @@ -118,6 +126,32 @@ public class MangaContext(DbContextOptions options) : DbContext(options) .OnDelete(DeleteBehavior.Cascade); } + private static void ConfigureContributor(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(x => x.ContributorId); + + modelBuilder + .Entity() + .HasIndex(x => x.Name) + .IsUnique(true); + } + + private static void ConfigureMangaContributor(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasKey(mc => new { mc.MangaId, mc.ContributorId, mc.Role }); + + modelBuilder + .Entity() + .HasOne(x => x.Manga) + .WithMany(x => x.Contributors) + .HasForeignKey(x => x.MangaId) + .OnDelete(DeleteBehavior.Cascade); + } + private static void ConfigureGenre(ModelBuilder modelBuilder) { modelBuilder diff --git a/MangaReader.Core/Data/MangaContributor.cs b/MangaReader.Core/Data/MangaContributor.cs new file mode 100644 index 0000000..eba712a --- /dev/null +++ b/MangaReader.Core/Data/MangaContributor.cs @@ -0,0 +1,12 @@ +namespace MangaReader.Core.Data; + +public class MangaContributor +{ + public int MangaId { get; set; } + public required Manga Manga { get; set; } + + public int ContributorId { get; set; } + public required Contributor Contributor { get; set; } + + public MangaContributorRole Role { get; set; } +} \ No newline at end of file diff --git a/MangaReader.Core/Data/MangaContributorRole.cs b/MangaReader.Core/Data/MangaContributorRole.cs new file mode 100644 index 0000000..95e6c04 --- /dev/null +++ b/MangaReader.Core/Data/MangaContributorRole.cs @@ -0,0 +1,7 @@ +namespace MangaReader.Core.Data; + +public enum MangaContributorRole +{ + Author, + Artist +} \ No newline at end of file diff --git a/MangaReader.Core/MangaReader.Core.csproj b/MangaReader.Core/MangaReader.Core.csproj index 0aa01e1..bc7f4ce 100644 --- a/MangaReader.Core/MangaReader.Core.csproj +++ b/MangaReader.Core/MangaReader.Core.csproj @@ -12,7 +12,7 @@ - + diff --git a/MangaReader.Core/Search/MangaDex/MangaDexSearchProvider.cs b/MangaReader.Core/Search/MangaDex/MangaDexSearchProvider.cs new file mode 100644 index 0000000..fdc4e21 --- /dev/null +++ b/MangaReader.Core/Search/MangaDex/MangaDexSearchProvider.cs @@ -0,0 +1,66 @@ +using MangaReader.Core.HttpService; +using System.Text; +using System.Text.RegularExpressions; + +namespace MangaReader.Core.Search.MangaDex; + +public class MangaDexSearchProvider(IHttpService httpService) : MangaSearchProviderBase(httpService) +{ + protected override string GetSearchUrl(string keyword) + { + string normalizedKeyword = keyword.ToLowerInvariant().Normalize(NormalizationForm.FormD); + + return $"https://api.mangadex.org/manga?title={normalizedKeyword}&limit=5"; + } + + protected override MangaSearchResult[] GetSearchResult(MangaDexSearchResult searchResult) + { + List mangaSearchResults = []; + + foreach (MangaDexSearchResultData searchResultData in searchResult.Data) + { + string title = searchResultData.Attributes.Title.FirstOrDefault().Value; + string slug = GenerateSlug(title); + + MangaSearchResult mangaSearchResult = new() + { + Source = "MangaDex", + Title = title, + Url = $"https://mangadex.org/title/{searchResultData.Id}/{slug}", + Thumbnail = GetThumbnail(searchResultData) + }; + + mangaSearchResults.Add(mangaSearchResult); + } + + return [.. mangaSearchResults]; + } + + public static string GenerateSlug(string title) + { + // title.ToLowerInvariant().Normalize(NormalizationForm.FormD); + + title = title.ToLowerInvariant(); + //title = Regex.Replace(title, @"[^a-z0-9\s-]", ""); // remove invalid chars + title = Regex.Replace(title, @"[^a-z0-9\s-]", "-"); // replace invalid chars with dash + title = Regex.Replace(title, @"\s+", "-"); // replace spaces with dash + + return title.Trim('-'); + } + + private static string? GetThumbnail(MangaDexSearchResultData searchResultData) + { + var coverArtRelationship = searchResultData.Relationships.FirstOrDefault(x => x.Type == "cover_art"); + + if (coverArtRelationship == null) + return null; + + if (coverArtRelationship.Attributes.TryGetValue("fileName", out object? fileNameValue) == false) + return null; + + if (fileNameValue == null) + return null; + + return $"https://mangadex.org/covers/{searchResultData.Id}/{fileNameValue}"; + } +} \ No newline at end of file diff --git a/MangaReader.Core/Search/MangaDex/MangaDexSearchResult.cs b/MangaReader.Core/Search/MangaDex/MangaDexSearchResult.cs new file mode 100644 index 0000000..ed48cdd --- /dev/null +++ b/MangaReader.Core/Search/MangaDex/MangaDexSearchResult.cs @@ -0,0 +1,8 @@ +namespace MangaReader.Core.Search.MangaDex; + +public class MangaDexSearchResult +{ + public required string Result { get; set; } + public required string Response { get; set; } + public MangaDexSearchResultData[] Data { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/Search/MangaDex/MangaDexSearchResultData.cs b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultData.cs new file mode 100644 index 0000000..9de0b0d --- /dev/null +++ b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultData.cs @@ -0,0 +1,8 @@ +namespace MangaReader.Core.Search.MangaDex; + +public class MangaDexSearchResultData +{ + public required Guid Id { get; set; } + public required MangaDexSearchResultDataAttributes Attributes { get; set; } + public MangaDexSearchResultDataRelationship[] Relationships { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataAttributes.cs b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataAttributes.cs new file mode 100644 index 0000000..1f68acc --- /dev/null +++ b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataAttributes.cs @@ -0,0 +1,8 @@ +namespace MangaReader.Core.Search.MangaDex; + +public class MangaDexSearchResultDataAttributes +{ + public Dictionary Title { get; set; } = []; + public List> AltTitles { get; set; } = []; + public Dictionary Description { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataRelationship.cs b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataRelationship.cs new file mode 100644 index 0000000..819c323 --- /dev/null +++ b/MangaReader.Core/Search/MangaDex/MangaDexSearchResultDataRelationship.cs @@ -0,0 +1,8 @@ +namespace MangaReader.Core.Search.MangaDex; + +public class MangaDexSearchResultDataRelationship +{ + public required Guid Id { get; set; } + public required string Type { get; set; } + public Dictionary Attributes { get; set; } = []; +} \ No newline at end of file diff --git a/MangaReader.Core/Search/MangaSearchProviderBase.cs b/MangaReader.Core/Search/MangaSearchProviderBase.cs index 92348b3..13897fd 100644 --- a/MangaReader.Core/Search/MangaSearchProviderBase.cs +++ b/MangaReader.Core/Search/MangaSearchProviderBase.cs @@ -5,7 +5,7 @@ namespace MangaReader.Core.Search; public abstract class MangaSearchProviderBase(IHttpService httpService) : IMangaSearchProvider { - private static JsonSerializerOptions _jsonSerializerOptions = new() + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; diff --git a/MangaReader.Core/Search/MangaSearchResult.cs b/MangaReader.Core/Search/MangaSearchResult.cs index bb71a1d..a2d7b3d 100644 --- a/MangaReader.Core/Search/MangaSearchResult.cs +++ b/MangaReader.Core/Search/MangaSearchResult.cs @@ -5,6 +5,7 @@ public record MangaSearchResult public required string Source { get; init; } public required string Url { get; init; } public required string Title { get; init; } + public string? Thumbnail { get; init; } public string? Author { get; init; } public string? Description { get; init; } } \ No newline at end of file diff --git a/MangaReader.Core/Search/NatoManga/NatoMangaSearchProvider.cs b/MangaReader.Core/Search/NatoManga/NatoMangaSearchProvider.cs index d578ce8..3a8f05d 100644 --- a/MangaReader.Core/Search/NatoManga/NatoMangaSearchProvider.cs +++ b/MangaReader.Core/Search/NatoManga/NatoMangaSearchProvider.cs @@ -1,14 +1,44 @@ using MangaReader.Core.HttpService; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; namespace MangaReader.Core.Search.NatoManga; public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProviderBase(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}"; + string formattedSeachWord = GetFormattedSearchWord(keyword); + + return $"https://www.natomanga.com/home/search/json?searchword={formattedSeachWord}"; + } + + private static string GetFormattedSearchWord(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + // Convert to lowercase and normalize to decompose accents + string normalized = input.ToLowerInvariant() + .Normalize(NormalizationForm.FormD); + + // Remove diacritics + var sb = new StringBuilder(); + foreach (var c in normalized) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + sb.Append(c); + } + + // Replace non-alphanumeric characters with underscores + string cleaned = Regex.Replace(sb.ToString(), @"[^a-z0-9]+", "_"); + + // Trim and collapse underscores + cleaned = Regex.Replace(cleaned, "_{2,}", "_").Trim('_'); + + return cleaned; } protected override MangaSearchResult[] GetSearchResult(NatoMangaSearchResult[] searchResult) @@ -18,6 +48,7 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv { Source = "NatoManga", Title = searchResult.Name, + Thumbnail = searchResult.Thumb, Url = searchResult.Url }); diff --git a/MangaReader.Core/WebCrawlers/SourceManga.cs b/MangaReader.Core/WebCrawlers/SourceManga.cs index 19e2663..896c883 100644 --- a/MangaReader.Core/WebCrawlers/SourceManga.cs +++ b/MangaReader.Core/WebCrawlers/SourceManga.cs @@ -6,6 +6,7 @@ public class SourceManga public string? Description { get; set; } public List AlternateTitles { get; set; } = []; public List Authors { get; set; } = []; + public List Artists { get; set; } = []; public MangaStatus Status { get; set; } = MangaStatus.Unknown; public List Genres { get; set; } = []; public DateTime? UpdateDate { get; set; } diff --git a/MangaReader.Tests/MangaReader.Tests.csproj b/MangaReader.Tests/MangaReader.Tests.csproj index c5a9f88..e77c1d2 100644 --- a/MangaReader.Tests/MangaReader.Tests.csproj +++ b/MangaReader.Tests/MangaReader.Tests.csproj @@ -10,8 +10,9 @@ + - + @@ -24,7 +25,8 @@ - + + diff --git a/MangaReader.Tests/Search/MangaDex/MangaDexSearchTests.cs b/MangaReader.Tests/Search/MangaDex/MangaDexSearchTests.cs new file mode 100644 index 0000000..88fd9a3 --- /dev/null +++ b/MangaReader.Tests/Search/MangaDex/MangaDexSearchTests.cs @@ -0,0 +1,50 @@ +using MangaReader.Core.HttpService; +using MangaReader.Core.Search; +using MangaReader.Core.Search.MangaDex; +using MangaReader.Tests.Utilities; +using NSubstitute; +using Shouldly; + +namespace MangaReader.Tests.Search.MangaDex; + +public class MangaDexSearchTests +{ + class MangaDexSearchProviderTestWrapper(IHttpService httpService) : MangaDexSearchProvider(httpService) + { + internal string Test_GetSearchUrl(string keyword) => GetSearchUrl(keyword); + } + + [Fact] + public void Get_Search_Url() + { + // Arrange + IHttpService httpService = Substitute.For(); + MangaDexSearchProviderTestWrapper searchProvider = new(httpService); + + // Act + string url = searchProvider.Test_GetSearchUrl("Gal can't be"); + + // Assert + url.ShouldBe("https://api.mangadex.org/manga?title=gal can't be&limit=5"); + } + + [Fact] + public async Task Get_Search_Result() + { + string resourceName = "MangaReader.Tests.Search.MangaDex.SampleSearchResult.json"; + string searchResultJson = await ResourceHelper.ReadJsonResourceAsync(resourceName); + + IHttpService httpService = Substitute.For(); + + httpService.GetStringAsync(Arg.Any()) + .Returns(Task.FromResult(searchResultJson)); + + MangaDexSearchProvider searchProvider = new(httpService); + MangaSearchResult[] searchResult = await searchProvider.SearchAsync("Gal Can't Be Kind"); + + searchResult.Length.ShouldBe(3); + searchResult[0].Title.ShouldBe("Gals Can’t Be Kind to Otaku!?"); + searchResult[0].Url.ShouldBe("https://mangadex.org/title/ee96e2b7-9af2-4864-9656-649f4d3b6fec/gals-can-t-be-kind-to-otaku"); + searchResult[0].Thumbnail.ShouldBe("https://mangadex.org/covers/ee96e2b7-9af2-4864-9656-649f4d3b6fec/6b3073de-bb65-4723-8113-6068bf8c6eb4.jpg"); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/Search/MangaDex/SampleSearchResult.json b/MangaReader.Tests/Search/MangaDex/SampleSearchResult.json new file mode 100644 index 0000000..0e5da7f --- /dev/null +++ b/MangaReader.Tests/Search/MangaDex/SampleSearchResult.json @@ -0,0 +1,426 @@ +{ + "result": "ok", + "response": "collection", + "data": [ + { + "id": "ee96e2b7-9af2-4864-9656-649f4d3b6fec", + "type": "manga", + "attributes": { + "title": { "en": "Gals Can\u2019t Be Kind to Otaku!?" }, + "altTitles": [ + { "ja": "\u30aa\u30bf\u30af\u306b\u512a\u3057\u3044\u30ae\u30e3\u30eb\u306f\u3044\u306a\u3044\uff01\uff1f" }, + { "ja-ro": "Otaku ni Yasashii Gal wa Inai!?" }, + { "ja-ro": "Otaku ni Yasashii Gyaru ha Inai!?" }, + { "en": "Gal Can't Be Kind to Otaku!?" }, + { "en": "Gals Can't Be Kind To A Geek!?" }, + { "zh": "\u6ca1\u6709\u8fa3\u59b9\u4f1a\u5bf9\u963f\u5b85\u6e29\u67d4!?" }, + { "pt-br": "Gals N\u00e3o Podem ser Gentis com Otakus!?" }, + { "es-la": "\u00bf\u00a1Las Gals no pueden ser amables con los Otakus!?" }, + { "vi": "Gyaru kh\u00f4ng th\u1ec3 t\u1eed t\u1ebf v\u1edbi Otaku \u01b0?" } + ], + "description": { + "en": "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...", + "ja": "\u3042\u307e\u308a\u5927\u304d\u306a\u58f0\u3067\u597d\u304d\u3068\u8a00\u3048\u306a\u3044\u201c\u5973\u5150\u5411\u3051\u30a2\u30cb\u30e1\u201d\u304c\u597d\u304d\u306a\u30aa\u30bf\u30af \u702c\u5c3e\u5353\u4e5f\u3002\u3042\u308b\u65e5\u3001\u30af\u30e9\u30b9\u306e\u30ae\u30e3\u30eb \u5929\u97f3\u3055\u3093\u3068\u4f0a\u5730\u77e5\u3055\u3093\u306b\u7d61\u307e\u308c\u305f\u306e\u3060\u304c\u3001\u4f55\u3084\u3089\u5929\u97f3\u3055\u3093\u3082\u30aa\u30bf\u30af\u306e\u5302\u3044\u304c\u2026\uff1f", + "vi": "M\u1ed9t anh b\u1ea1n trung b\u00ecnh Otaku th\u00edch anime d\u00e0nh cho b\u00e9 g\u00e1i, gi\u00e1p m\u1eb7t hai n\u00e0ng Gyaru xinh \u0111\u1eb9p n\u1ed5i ti\u1ebfng nh\u1ea5t tr\u01b0\u1eddng. Nh\u01b0ng kh\u00f4ng ch\u1ec9 c\u00f3 th\u1ebf, m\u1ed9t trong hai n\u1eef nh\u00e2n xinh \u0111\u1eb9p \u1ea5y c\u00f3 g\u00ec \u0111\u00f3...h\u01a1i otaku th\u00ec ph\u1ea3i...?", + "es-la": "Takuya Seo es un otaku al que le gusta el \"anime para chicas\" y no puede decir que le guste en voz alta. Un d\u00eda, se junta con dos chicas de su clase, Amane e Ijichi, pero parece que Amane tambi\u00e9n es una otaku...", + "pt-br": "Takuya Seo \u00e9 um otaku que gosta de \"animes para garotinhas\" e n\u00e3o pode dizer isso em voz alta. Um dia, ele conversa com duas gals da sua sala, Amane e Ijichi, mas parece que a Amane tamb\u00e9m \u00e9 uma otaku... Uma com\u00e9dia rom\u00e2ntica escolar onde o otaku conhece as gals que ele gosta!" + }, + "isLocked": false, + "links": { + "al": "138380", + "ap": "otaku-ni-yasashii-gal-wa-inai", + "bw": "series\/339484", + "kt": "69614", + "mu": "188325", + "amz": "https:\/\/www.amazon.co.jp\/dp\/B0BB2R5WVF", + "cdj": "https:\/\/www.cdjapan.co.jp\/searchuni?q=\u30aa\u30bf\u30af\u306b\u512a\u3057\u3044\u30ae\u30e3\u30eb\u306f\u3044\u306a\u3044\uff01\uff1f+(Zenon+Comics)&order=relasc", + "ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/690493\/", + "mal": "144152", + "raw": "https:\/\/comic-zenon.com\/episode\/3269754496560134267", + "engtl": "https:\/\/x.com\/yenpress\/status\/1913348424826581290" + }, + "originalLanguage": "ja", + "lastVolume": "", + "lastChapter": "", + "publicationDemographic": "seinen", + "status": "ongoing", + "year": 2021, + "contentRating": "safe", + "tags": [ + { + "id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d", + "type": "tag", + "attributes": { + "name": { "en": "Romance" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "4d32cc48-9f00-4cca-9b5a-a839f0764984", + "type": "tag", + "attributes": { + "name": { "en": "Comedy" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "caaa44eb-cd40-4177-b930-79d3ef2afe87", + "type": "tag", + "attributes": { + "name": { "en": "School Life" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + }, + { + "id": "e5301a23-ebd9-49dd-a0cb-2add944c7fe9", + "type": "tag", + "attributes": { + "name": { "en": "Slice of Life" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "fad12b5e-68ba-460e-b933-9ae8318f5b65", + "type": "tag", + "attributes": { + "name": { "en": "Gyaru" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + } + ], + "state": "published", + "chapterNumbersResetOnNewVolume": false, + "createdAt": "2022-03-08T07:41:47+00:00", + "updatedAt": "2025-04-19T23:05:17+00:00", + "version": 44, + "availableTranslatedLanguages": [ "it", "es-la", "en", "id", "fr", "vi", "pt-br" ], + "latestUploadedChapter": "9e8f9776-a8bf-4118-83df-f0d086479d64" + }, + "relationships": [ + { + "id": "518965e7-c26c-4fd9-881c-f7ce0e78323d", + "type": "author" + }, + { + "id": "767b8851-6060-478a-a23e-f819fec0fbf2", + "type": "artist" + }, + { + "id": "a06943fd-6309-49a8-a66a-8df0f6dc41eb", + "type": "cover_art", + "attributes": { + "description": "Volume 9 Cover from BookLive", + "volume": "9", + "fileName": "6b3073de-bb65-4723-8113-6068bf8c6eb4.jpg", + "locale": "ja", + "createdAt": "2025-02-20T11:59:45+00:00", + "updatedAt": "2025-02-20T11:59:45+00:00", + "version": 1 + } + }, + { + "id": "6c0a87bf-3adb-4854-a4a6-d5c358b73d21", + "type": "creator" + } + ] + }, + { + "id": "16c34950-954c-4f0d-808e-d8278a546339", + "type": "manga", + "attributes": { + "title": { "en": "Suufungo no Mirai ga Wakaru You ni Natta Kedo, Onnagokoro wa Wakaranai." }, + "altTitles": [ + { "ja": "\u6570\u5206\u5f8c\u306e\u672a\u6765\u304c\u5206\u304b\u308b\u3088\u3046\u306b\u306a\u3063\u305f\u3051\u3069\u3001\u5973\u5fc3\u306f\u5206\u304b\u3089\u306a\u3044\u3002" }, + { "ja-ro": "Suufungo no Mirai ga Wakaru You ni Natta Kedo, Onnagokoro ha Wakaranai." }, + { "en": "I Can Now See a Few Minutes Into the Future, but I Still Can't Understand a Woman's Heart." }, + { "en": "I Can See a Few Minutes Into the Future, but I Don\u2019t Know What a Woman's Mind Is Like." }, + { "id": "Sekarang saya dapat melihat masa depan dalam beberapa menit, tetapi saya tidak dapat memahami hati seorang wanita." }, + { "pt-br": "Agora, posso ver alguns minutos no futuro, mas ainda n\u00e3o consigo entender o cora\u00e7\u00e3o de uma mulher." }, + { "vi": "T\u00f4i Gi\u1edd C\u00f3 Th\u1ec3 Nh\u00ecn Th\u1ea5y V\u00e0i Kh\u1eafc Trong T\u01b0\u01a1ng Lai, Nh\u01b0ng V\u1eabn Kh\u00f4ng Th\u1ec3 Hi\u1ec3u \u0110\u01b0\u1ee3c Con G\u00e1i Ngh\u0129 G\u00ec" } + ], + "description": { + "en": "Arase Itou has the ability to see a few minutes into the future, but the glimpses he sees are always linked to misfortune. With his kind-hearted nature, Arata decides to use this inconvenient power to help girls facing various troubles and prevent their unfortunate futures. However, Arase doesn't realize that, little by little, the girls are growing fond of him. And he remains oblivious to the quiet rivalries forming between them over his attention.", + "id": "Arase Itou bisa melihat beberapa menit ke depan. Namun, masa depan yang ia lihat selalu berhubungan dengan masalah. Arase yang baik hati berusaha menghindari masa depan yang tidak menyenangkan ini, namun pada akhirnya masalah tersebut tetap membuatnya membantu berbagai gadis dengan masalah mereka. Tanpa ia sadari, para gadis mulai menyukainya. Arase tidak menyadari bahwa, diam-diam, para gadis bersaing untuk memperebutkannya.", + "ja": "\u4f0a\u85e4\u65b0\u4e16\u306f\u6570\u5206\u5f8c\u306e\u672a\u6765\u304c\u5206\u304b\u308b\u3002\u305f\u3060\u3057\u8996\u3048\u308b\u672a\u6765\u306f\u4e0d\u5e78\u306b\u3064\u306a\u304c\u308b\u3082\u306e\u3002\n\u304a\u4eba\u597d\u3057\u306a\u65b0\u4e16\u306f\u305d\u3093\u306a\u4e0d\u90fd\u5408\u306a\u672a\u6765\u3092\u56de\u907f\u3057\u3001\u69d8\u3005\u306a\u60a9\u307f\u3092\u6301\u3064\u5c11\u5973\u3092\u52a9\u3051\u308b\u3053\u3068\u306b\u306a\u308b\u3002\n\u65b0\u4e16\u306f\u77e5\u3089\u306a\u3044\u3002\u3044\u3064\u306e\u9593\u306b\u304b\u3001\u5c11\u5973\u305f\u3061\u306b\u597d\u304b\u308c\u3066\u3044\u308b\u3053\u3068\u306b\u3002\n\u65b0\u4e16\u306f\u6c17\u3065\u304b\u306a\u3044\u3002\u4eba\u77e5\u308c\u305a\u3001\u5c11\u5973\u305f\u3061\u304c\u4e89\u3063\u3066\u3044\u308b\u3053\u3068\u306b\u3002", + "vi": "Arata Itou c\u00f3 kh\u1ea3 n\u0103ng nh\u00ecn th\u1ea5y tr\u01b0\u1edbc v\u00e0i ph\u00fat trong t\u01b0\u01a1ng lai, nh\u01b0ng nh\u1eefng h\u00ecnh \u1ea3nh c\u1eadu th\u1ea5y lu\u00f4n g\u1eafn li\u1ec1n v\u1edbi xui x\u1ebbo. L\u00e0 m\u1ed9t ng\u01b0\u1eddi t\u1ed1t b\u1ee5ng, Arata quy\u1ebft \u0111\u1ecbnh t\u1eadn d\u1ee5ng n\u0103ng l\u1ef1c \"b\u1ea5t ti\u1ec7n\" n\u00e0y \u0111\u1ec3 gi\u00fap \u0111\u1ee1 c\u00e1c c\u00f4 g\u00e1i \u0111ang g\u1eb7p kh\u00f3 kh\u0103n v\u00e0 ng\u0103n ch\u1eb7n nh\u1eefng \u0111i\u1ec1u kh\u00f4ng may s\u1ebd x\u1ea3y ra v\u1edbi h\u1ecd. Th\u1ebf nh\u01b0ng, c\u1eadu ho\u00e0n to\u00e0n kh\u00f4ng nh\u1eadn ra r\u1eb1ng c\u00e1c c\u00f4 g\u00e1i ng\u00e0y c\u00e0ng c\u00f3 t\u00ecnh c\u1ea3m v\u1edbi m\u00ecnh. C\u00e0ng kh\u00f4ng hay bi\u1ebft v\u1ec1 nh\u1eefng \"cu\u1ed9c chi\u1ebfn ng\u1ea7m\" gi\u1eefa h\u1ecd \u0111\u1ec3 gi\u00e0nh l\u1ea5y s\u1ef1 ch\u00fa \u00fd c\u1ee7a c\u1eadu.", + "pt-br": "Arata Itou tem a habilidade de ver alguns minutos no futuro, mas as vis\u00f5es que ele tem est\u00e3o sempre ligadas a infort\u00fanios. Com seu cora\u00e7\u00e3o bondoso, Arata decide usar esse poder inconveniente para ajudar as garotas que enfrentam diversos problemas e evitar seus futuros infelizes. No entanto, Arata n\u00e3o percebe que, pouco a pouco, as garotas come\u00e7am a se afei\u00e7oar a ele. E ele continua alheio \u00e0s rivalidades silenciosas que est\u00e3o se formando entre elas por causa da sua aten\u00e7\u00e3o." + }, + "isLocked": false, + "links": { + "al": "184387", + "ap": "suufungo-no-mirai-ga-wakaru-you-ni-natta-kedo-onnagokoro-wa-wakaranai", + "bw": "series\/501373\/list", + "kt": "73470", + "mu": "6et7o73", + "nu": "i-can-see-a-few-minutes-into-the-future-but-i-dont-know-what-a-womans-mind-is-like", + "amz": "https:\/\/www.amazon.co.jp\/dp\/B0DPG735ZF", + "cdj": "https:\/\/www.cdjapan.co.jp\/product\/NEOBK-3045342", + "ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/867769", + "mal": "176205", + "raw": "https:\/\/comic-walker.com\/detail\/KC_005690_S?episodeType=first" + }, + "originalLanguage": "ja", + "lastVolume": "", + "lastChapter": "", + "publicationDemographic": "shounen", + "status": "ongoing", + "year": 2024, + "contentRating": "safe", + "tags": [ + { + "id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d", + "type": "tag", + "attributes": { + "name": { "en": "Romance" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "4d32cc48-9f00-4cca-9b5a-a839f0764984", + "type": "tag", + "attributes": { + "name": { "en": "Comedy" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "aafb99c1-7f60-43fa-b75f-fc9502ce29c7", + "type": "tag", + "attributes": { + "name": { "en": "Harem" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + }, + { + "id": "caaa44eb-cd40-4177-b930-79d3ef2afe87", + "type": "tag", + "attributes": { + "name": { "en": "School Life" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + }, + { + "id": "eabc5b4c-6aff-42f3-b657-3e90cbd00b75", + "type": "tag", + "attributes": { + "name": { "en": "Supernatural" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + }, + { + "id": "f4122d1c-3b44-44d0-9936-ff7502c39ad3", + "type": "tag", + "attributes": { + "name": { "en": "Adaptation" }, + "description": {}, + "group": "format", + "version": 1 + }, + "relationships": [] + } + ], + "state": "published", + "chapterNumbersResetOnNewVolume": false, + "createdAt": "2024-11-10T00:54:16+00:00", + "updatedAt": "2025-04-16T06:18:51+00:00", + "version": 30, + "availableTranslatedLanguages": [ "es-la", "id", "pt-br", "en", "ru", "vi" ], + "latestUploadedChapter": "f68c92d1-a701-4136-b2bf-7716d581345e" + }, + "relationships": [ + { + "id": "cf0872ad-dcb7-466b-bbf0-d158d454e49c", + "type": "author" + }, + { + "id": "1459d711-c584-4594-9bb5-bbbe646b69cc", + "type": "artist" + }, + { + "id": "ee8588b5-145f-4eee-981a-eb604856fbd2", + "type": "cover_art", + "attributes": { + "description": "Volume 1 Cover from BookLive", + "volume": "1", + "fileName": "7d301e1e-642b-4b7d-b65b-9777b36e80bf.jpg", + "locale": "ja", + "createdAt": "2024-12-27T20:40:30+00:00", + "updatedAt": "2024-12-27T20:40:30+00:00", + "version": 1 + } + }, + { + "id": "3e37a8a0-2012-4d3d-9495-cbed6d0155d8", + "type": "creator" + } + ] + }, + { + "id": "f395bfc6-e52f-4f64-9cfb-87037215d214", + "type": "manga", + "attributes": { + "title": { "en": "Ienai Himitsu No Aishikata" }, + "altTitles": [ + { "ja": "\u3044\u3048\u306a\u3044\u79d8\u5bc6\u306e\u611b\u3057\u65b9" }, + { "en": "How To Love: A Secret That Cannot Be Told" }, + { "zh": "\u65e0\u6cd5\u544a\u4eba\u7684\u79d8\u5bc6\u7231\u597d" }, + { "zh-hk": "\u7121\u6cd5\u544a\u4eba\u7684\u79d8\u5bc6\u611b\u597d" }, + { "zh-ro": "W\u00faf\u01ce g\u00e0o r\u00e9n de m\u00ecm\u00ec \u00e0ih\u00e0o" }, + { "ru": "\u041b\u044e\u0431\u043e\u0432\u043d\u0430\u044f \u0442\u0430\u0439\u043d\u0430, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043b\u044c\u0437\u044f \u043f\u043e\u0432\u0435\u0434\u0430\u0442\u044c" } + ], + "description": { + "en": "High School teacher Saeki Nao is secretly a hardcore otaku who loves yuri manga. One day, at a doujinshi convention they participate in, Nao is eager to convey her feelings to \"Nyapoleon\", an artist she worships. However, it turned out to be a girl from her school, Kurumizawa Haruka, and moreover, she had to help her. After the event, they decided to go out for a meal, at Haruka's suggestion... When she wakes up in the morning, there is Haruka in the bed...?!", + "ja": "\u9ad8\u6821\u6559\u5e2b\u30fb\u4f50\u4f2f\u548c\u7dd2\u306f\u5bc6\u304b\u306b\u767e\u5408\u6f2b\u753b\u3092\u611b\u3059\u308b\u30aa\u30bf\u30af\u5973\u5b50\u3002\u3042\u308b\u65e5\u3001\u53c2\u52a0\u3057\u305f\u540c\u4eba\u8a8c\u5373\u58f2\u4f1a\u3067\u3001\u5d07\u62dd\u3059\u308b\u7d75\u5e2b\u300c\u306b\u3083\u30dd\u30ec\u30aa\u30f3\u300d\u306b\u60f3\u3044\u3092\u4f1d\u3048\u3088\u3046\u3068\u610f\u6c17\u8fbc\u3080\u548c\u7dd2\u3002\u3060\u304c\u305d\u3053\u306b\u3044\u305f\u306e\u306f\u2026!?", + "pt-br": "Kazuo Saeki, uma professora do ensino m\u00e9dio, \u00e9 uma otaku que ama secretamente produtos yuri.\nUm dia, em um evento de venda doujinshi do qual ela participou, Kazuo estava entusiasmada para transmitir seus pensamentos ao autor \u201cNyapoleon\u201d.\nMas quem ela encontrou l\u00e1 foi\u2026" + }, + "isLocked": false, + "links": { + "al": "128964", + "ap": "ienai-himitsu-no-aishikata", + "bw": "series\/299385", + "mu": "gmbglvo", + "amz": "https:\/\/www.amazon.co.jp\/dp\/B0CR4CG491", + "ebj": "https:\/\/ebookjapan.yahoo.co.jp\/books\/636287", + "mal": "131861", + "raw": "https:\/\/storia.takeshobo.co.jp\/manga\/himitsunoaishikata" + }, + "originalLanguage": "ja", + "lastVolume": "2", + "lastChapter": "12", + "publicationDemographic": "seinen", + "status": "completed", + "year": 2021, + "contentRating": "suggestive", + "tags": [ + { + "id": "423e2eae-a7a2-4a8b-ac03-a8351462d71d", + "type": "tag", + "attributes": { + "name": { "en": "Romance" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "4d32cc48-9f00-4cca-9b5a-a839f0764984", + "type": "tag", + "attributes": { + "name": { "en": "Comedy" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "a3c67850-4684-404e-9b7f-c69850ee5da6", + "type": "tag", + "attributes": { + "name": { "en": "Girls' Love" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "b9af3a63-f058-46de-a9a0-e0c13906197a", + "type": "tag", + "attributes": { + "name": { "en": "Drama" }, + "description": {}, + "group": "genre", + "version": 1 + }, + "relationships": [] + }, + { + "id": "caaa44eb-cd40-4177-b930-79d3ef2afe87", + "type": "tag", + "attributes": { + "name": { "en": "School Life" }, + "description": {}, + "group": "theme", + "version": 1 + }, + "relationships": [] + } + ], + "state": "published", + "chapterNumbersResetOnNewVolume": false, + "createdAt": "2021-11-24T01:19:31+00:00", + "updatedAt": "2025-02-24T16:58:42+00:00", + "version": 18, + "availableTranslatedLanguages": [ "ko", "en", "ga" ], + "latestUploadedChapter": "ab661694-2098-46fb-a3f5-bbc1c3e3cd64" + }, + "relationships": [ + { + "id": "a0e14841-b86a-499f-af44-c3fa6f35c424", + "type": "author" + }, + { + "id": "a0e14841-b86a-499f-af44-c3fa6f35c424", + "type": "artist" + }, + { + "id": "40df2d2e-b786-4aa9-9218-e3ed168cd96e", + "type": "cover_art", + "attributes": { + "description": "", + "volume": "2", + "fileName": "c00a33cd-b26b-4554-a9f0-d6885c81eb36.jpg", + "locale": "ja", + "createdAt": "2024-09-14T22:32:25+00:00", + "updatedAt": "2024-09-14T22:32:25+00:00", + "version": 1 + } + }, + { + "id": "df805b8c-0c05-40f2-8f92-0f096ca346e4", + "type": "manga", + "related": "preserialization" + }, + { + "id": "f8cc4f8a-e596-4618-ab05-ef6572980bbf", + "type": "creator" + } + ] + } + ], + "limit": 5, + "offset": 0, + "total": 3 +} \ No newline at end of file diff --git a/MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs b/MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs similarity index 57% rename from MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs rename to MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs index 194d303..00207c5 100644 --- a/MangaReader.Tests/WebSearch/NatoManga/NatoMangaWebSearchTests.cs +++ b/MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs @@ -5,14 +5,28 @@ using MangaReader.Tests.Utilities; using NSubstitute; using Shouldly; -namespace MangaReader.Tests.WebSearch.NatoManga; +namespace MangaReader.Tests.Search.NatoManga; public class NatoMangaWebSearchTests { + [Fact] + public void Get_Search_Url() + { + // Arrange + IHttpService httpService = Substitute.For(); + NatoMangaSearchProviderTestWrapper searchProvider = new(httpService); + + // Act + string url = searchProvider.Test_GetSearchUrl("Gal can't be kind"); + + // Assert + url.ShouldBe("https://www.natomanga.com/home/search/json?searchword=gal_can_t_be_kind"); + } + [Fact] public async Task Get_Search_Result() { - string resourceName = "MangaReader.Tests.WebSearch.NatoManga.SampleSearchResult.json"; + string resourceName = "MangaReader.Tests.Search.NatoManga.SampleSearchResult.json"; string searchResultJson = await ResourceHelper.ReadJsonResourceAsync(resourceName); IHttpService httpService = Substitute.For(); @@ -21,7 +35,7 @@ public class NatoMangaWebSearchTests .Returns(Task.FromResult(searchResultJson)); NatoMangaSearchProvider searchProvider = new(httpService); - MangaSearchResult[] searchResult = await searchProvider.SearchAsync("Gals Can't Be Kind"); + MangaSearchResult[] searchResult = await searchProvider.SearchAsync("Gal Can't Be Kind"); searchResult.Length.ShouldBe(2); searchResult[0].Title.ShouldBe("Gal Can't Be Kind to Otaku!"); @@ -29,4 +43,9 @@ public class NatoMangaWebSearchTests 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"); } +} + +internal class NatoMangaSearchProviderTestWrapper(IHttpService httpService) : NatoMangaSearchProvider(httpService) +{ + internal string Test_GetSearchUrl(string keyword) => GetSearchUrl(keyword); } \ No newline at end of file diff --git a/MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json b/MangaReader.Tests/Search/NatoManga/SampleSearchResult.json similarity index 100% rename from MangaReader.Tests/WebSearch/NatoManga/SampleSearchResult.json rename to MangaReader.Tests/Search/NatoManga/SampleSearchResult.json