Added contributor classes for manga. Implemented MangaDex search.

This commit is contained in:
2025-05-24 15:56:44 -04:00
parent f760cff21f
commit 1a752bb57e
20 changed files with 706 additions and 24 deletions

View File

@@ -0,0 +1,9 @@
namespace MangaReader.Core.Data;
public class Contributor
{
public int ContributorId { get; set; }
public required string Name { get; set; }
public ICollection<MangaContributor> MangaContributions { get; set; } = [];
}

View File

@@ -7,18 +7,10 @@ public class Manga
public required string Title { get; set; }
public string? Description { get; set; }
public virtual ICollection<MangaCover> Covers { get; set; }
public virtual ICollection<MangaTitle> Titles { get; set; }
public virtual ICollection<MangaSource> Sources { get; set; }
public virtual ICollection<MangaGenre> Genres { get; set; }
public virtual ICollection<MangaChapter> Chapters { get; set; }
public Manga()
{
Covers = new HashSet<MangaCover>();
Titles = new HashSet<MangaTitle>();
Sources = new HashSet<MangaSource>();
Genres = new HashSet<MangaGenre>();
Chapters = new HashSet<MangaChapter>();
}
public virtual ICollection<MangaCover> Covers { get; set; } = [];
public virtual ICollection<MangaTitle> Titles { get; set; } = [];
public virtual ICollection<MangaSource> Sources { get; set; } = [];
public virtual ICollection<MangaContributor> Contributors { get; set; } = [];
public virtual ICollection<MangaGenre> Genres { get; set; } = [];
public virtual ICollection<MangaChapter> Chapters { get; set; } = [];
}

View File

@@ -9,6 +9,8 @@ public class MangaContext(DbContextOptions options) : DbContext(options)
public DbSet<MangaTitle> MangaTitles { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<MangaSource> MangaSources { get; set; }
public DbSet<Contributor> Contributors { get; set; }
public DbSet<MangaContributor> MangaContributors { get; set; }
public DbSet<Genre> Genres { get; set; }
public DbSet<MangaGenre> MangaGenres { get; set; }
public DbSet<MangaChapter> 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<Manga>()
.HasKey(x => x.MangaId);
modelBuilder.Entity<Manga>()
.HasIndex(x => x.Title)
.IsUnique();
modelBuilder.Entity<Manga>()
.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<Contributor>()
.HasKey(x => x.ContributorId);
modelBuilder
.Entity<Contributor>()
.HasIndex(x => x.Name)
.IsUnique(true);
}
private static void ConfigureMangaContributor(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<MangaContributor>()
.HasKey(mc => new { mc.MangaId, mc.ContributorId, mc.Role });
modelBuilder
.Entity<MangaContributor>()
.HasOne(x => x.Manga)
.WithMany(x => x.Contributors)
.HasForeignKey(x => x.MangaId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureGenre(ModelBuilder modelBuilder)
{
modelBuilder

View File

@@ -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; }
}

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Data;
public enum MangaContributorRole
{
Author,
Artist
}

View File

@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Search\MangaDex\" />
<Folder Include="Utilities\" />
</ItemGroup>
</Project>

View File

@@ -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<MangaDexSearchResult>(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<MangaSearchResult> 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}";
}
}

View File

@@ -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; } = [];
}

View File

@@ -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; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace MangaReader.Core.Search.MangaDex;
public class MangaDexSearchResultDataAttributes
{
public Dictionary<string, string> Title { get; set; } = [];
public List<Dictionary<string, string>> AltTitles { get; set; } = [];
public Dictionary<string, string> Description { get; set; } = [];
}

View File

@@ -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<string, object> Attributes { get; set; } = [];
}

View File

@@ -5,7 +5,7 @@ namespace MangaReader.Core.Search;
public abstract class MangaSearchProviderBase<T>(IHttpService httpService) : IMangaSearchProvider<T>
{
private static JsonSerializerOptions _jsonSerializerOptions = new()
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true
};

View File

@@ -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; }
}

View File

@@ -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<NatoMangaSearchResult[]>(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
});

View File

@@ -6,6 +6,7 @@ public class SourceManga
public string? Description { get; set; }
public List<string> AlternateTitles { get; set; } = [];
public List<string> Authors { get; set; } = [];
public List<string> Artists { get; set; } = [];
public MangaStatus Status { get; set; } = MangaStatus.Unknown;
public List<string> Genres { get; set; } = [];
public DateTime? UpdateDate { get; set; }

View File

@@ -10,8 +10,9 @@
</PropertyGroup>
<ItemGroup>
<None Remove="Search\MangaDex\SampleSearchResult.json" />
<None Remove="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm" />
<None Remove="WebSearch\NatoManga\SampleSearchResult.json" />
<None Remove="Search\NatoManga\SampleSearchResult.json" />
</ItemGroup>
<ItemGroup>
@@ -24,7 +25,8 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="WebSearch\NatoManga\SampleSearchResult.json" />
<EmbeddedResource Include="Search\MangaDex\SampleSearchResult.json" />
<EmbeddedResource Include="Search\NatoManga\SampleSearchResult.json" />
</ItemGroup>
<ItemGroup>

View File

@@ -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<IHttpService>();
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<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>())
.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 Cant 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");
}
}

View File

@@ -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
}

View File

@@ -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<IHttpService>();
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<IHttpService>();
@@ -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!");
@@ -30,3 +44,8 @@ public class NatoMangaWebSearchTests
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);
}