Added MangaDex Api. Updated project structure.

This commit is contained in:
2025-05-26 17:16:25 -04:00
parent 648aa95f32
commit ea8b4a36ff
61 changed files with 4937 additions and 197 deletions

View File

@@ -1,6 +1,6 @@
using MangaReader.Core.Search; using MangaReader.Core.Search;
using MangaReader.Core.Search.MangaDex; using MangaReader.Core.Search.MangaDex;
using MangaReader.Core.Search.NatoManga; using MangaReader.Core.Sources.NatoManga.Search;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;

View File

@@ -2,5 +2,6 @@
public class HttpService(HttpClient httpClient) : IHttpService public class HttpService(HttpClient httpClient) : IHttpService
{ {
public Task<string> GetStringAsync(string url) => httpClient.GetStringAsync(url); public Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
=> httpClient.GetStringAsync(url, cancellationToken);
} }

View File

@@ -2,5 +2,5 @@
public interface IHttpService public interface IHttpService
{ {
Task<string> GetStringAsync(string url); Task<string> GetStringAsync(string url, CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Metadata;
public interface IMangaMetadataProvider
{
SourceManga GetManga(string url);
}

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.WebCrawlers; namespace MangaReader.Core.Metadata;
public enum MangaStatus public enum MangaStatus
{ {

View File

@@ -1,8 +1,8 @@
using HtmlAgilityPack; using HtmlAgilityPack;
namespace MangaReader.Core.WebCrawlers; namespace MangaReader.Core.Metadata;
public abstract class MangaWebCrawler : IMangaWebCrawler public abstract class MangaWebCrawler : IMangaMetadataProvider
{ {
public abstract SourceManga GetManga(string url); public abstract SourceManga GetManga(string url);

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.WebCrawlers; namespace MangaReader.Core.Metadata;
public class SourceManga public class SourceManga
{ {

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.WebCrawlers; namespace MangaReader.Core.Metadata;
public class SourceMangaChapter public class SourceMangaChapter
{ {

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.WebCrawlers; using MangaReader.Core.Metadata;
namespace MangaReader.Core.Pipeline; namespace MangaReader.Core.Pipeline;

View File

@@ -1,5 +1,5 @@
using MangaReader.Core.Data; using MangaReader.Core.Data;
using MangaReader.Core.WebCrawlers; using MangaReader.Core.Metadata;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;

View File

@@ -2,5 +2,5 @@
public interface IMangaSearchCoordinator public interface IMangaSearchCoordinator
{ {
Task<MangaSearchResult[]> SearchAsync(string keyword, CancellationToken cancellationToken); Task<Dictionary<string, MangaSearchResult[]>> SearchAsync(string keyword, CancellationToken cancellationToken);
} }

View File

@@ -1,12 +1,25 @@
namespace MangaReader.Core.Search; using MangaReader.Core.Sources;
namespace MangaReader.Core.Search;
public class MangaSearchCoordinator(IEnumerable<IMangaSearchProvider> searchProviders) : IMangaSearchCoordinator public class MangaSearchCoordinator(IEnumerable<IMangaSearchProvider> searchProviders) : IMangaSearchCoordinator
{ {
public async Task<MangaSearchResult[]> SearchAsync(string keyword, CancellationToken cancellationToken) public async Task<Dictionary<string, MangaSearchResult[]>> SearchAsync(string keyword, CancellationToken cancellationToken)
{ {
var searchTasks = searchProviders.Select(searchProvider => searchProvider.SearchAsync(keyword, cancellationToken)); var searchTasks = searchProviders.Select(searchProvider =>
GetMangaSearchResultsAsync(searchProvider, keyword, cancellationToken));
var allResults = await Task.WhenAll(searchTasks); var allResults = await Task.WhenAll(searchTasks);
return [.. allResults.SelectMany(mangaSearchResults => mangaSearchResults)]; return allResults.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
private static async Task<KeyValuePair<string, MangaSearchResult[]>> GetMangaSearchResultsAsync(
IMangaSearchProvider searchProvider, string keyword, CancellationToken cancellationToken)
{
string sourceName = searchProvider is IMangaSourceComponent sourceComponent ? sourceComponent.SourceId : "Unknown";
MangaSearchResult[] results = await searchProvider.SearchAsync(keyword, cancellationToken);
return new KeyValuePair<string, MangaSearchResult[]>(sourceName, results);
} }
} }

View File

@@ -12,7 +12,7 @@ public abstract class MangaSearchProviderBase<T>(IHttpService httpService) : IMa
public async Task<MangaSearchResult[]> SearchAsync(string keyword, CancellationToken cancellationToken) public async Task<MangaSearchResult[]> SearchAsync(string keyword, CancellationToken cancellationToken)
{ {
T? searchResult = await GetSearchResultAsync(keyword); T? searchResult = await GetSearchResultAsync(keyword, cancellationToken);
if (searchResult == null) if (searchResult == null)
return []; return [];
@@ -20,10 +20,10 @@ public abstract class MangaSearchProviderBase<T>(IHttpService httpService) : IMa
return GetSearchResult(searchResult); return GetSearchResult(searchResult);
} }
private async Task<T?> GetSearchResultAsync(string keyword) private async Task<T?> GetSearchResultAsync(string keyword, CancellationToken cancellationToken)
{ {
string url = GetSearchUrl(keyword); string url = GetSearchUrl(keyword);
string response = await httpService.GetStringAsync(url); string response = await httpService.GetStringAsync(url, cancellationToken);
return JsonSerializer.Deserialize<T>(response, _jsonSerializerOptions); return JsonSerializer.Deserialize<T>(response, _jsonSerializerOptions);
} }

View File

@@ -2,7 +2,6 @@
public record MangaSearchResult public record MangaSearchResult
{ {
public required string Source { get; init; }
public required string Url { get; init; } public required string Url { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public string? Thumbnail { get; init; } public string? Thumbnail { get; init; }

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources;
public interface IMangaSourceComponent
{
string SourceId { get; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ArtistAttributes : PersonAttributes
{
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ArtistEntity : MangaDexEntity
{
public required ArtistAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class AuthorAttributes : PersonAttributes
{
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class AuthorEntity : MangaDexEntity
{
public required AuthorAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ChapterAttributes
{
public string? Chapter { get; set; }
public string? Title { get; set; }
public int Pages { get; set; }
public DateTime PublishAt { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ChapterEntity : MangaDexEntity
{
public required ChapterAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class CoverArtAttributes
{
public string? Description { get; set; }
public string? Volume { get; set; }
public required string FileName { get; set; }
public string? Locale { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int Version { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class CoverArtEntity : MangaDexEntity
{
public required CoverArtAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class CreatorEntity : MangaDexEntity
{
}

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public interface IMangaDexClient
{
Task<MangaDexResponse> GetMangaAsync(Guid mangaGuid, CancellationToken cancellationToken);
Task<MangaDexResponse> GetFeedAsync(Guid mangaGuid, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaAttributes
{
public Dictionary<string, string> Title { get; set; } = [];
public List<Dictionary<string, string>> AltTitles { get; set; } = [];
public Dictionary<string, string> Description { get; set; } = [];
public List<TagEntity> Tags { get; set; } = [];
}

View File

@@ -0,0 +1,39 @@
using MangaReader.Core.HttpService;
using System.Text.Json;
namespace MangaReader.Core.Sources.MangaDex.Api
{
public class MangaDexClient(IHttpService httpService) : IMangaDexClient
{
private static readonly JsonSerializerOptions _jsonSerializerOptions;
static MangaDexClient()
{
_jsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
_jsonSerializerOptions.Converters.Add(new MangaDexResponseConverter());
_jsonSerializerOptions.Converters.Add(new MangaDexEntityConverter());
}
private async Task<MangaDexResponse> GetAsync(string url, CancellationToken cancellationToken)
{
string response = await httpService.GetStringAsync(url, cancellationToken);
return JsonSerializer.Deserialize<MangaDexResponse>(response, _jsonSerializerOptions)
?? new() { Response = "failed", Result = "unknown" };
}
public async Task<MangaDexResponse> GetMangaAsync(Guid mangaGuid, CancellationToken cancellationToken)
{
return await GetAsync($"https://api.mangadex.org/manga/{mangaGuid}?includes[]=artist&includes[]=author&includes[]=cover_art", cancellationToken);
}
public async Task<MangaDexResponse> GetFeedAsync(Guid mangaGuid, CancellationToken cancellationToken)
{
return await GetAsync($"https://api.mangadex.org/manga/{mangaGuid}/feed?translatedLanguage[]=en&limit=96&includes[]=scanlation_group&includes[]=user&order[volume]=desc&order[chapter]=desc&offset=0&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic", cancellationToken);
}
}
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexCollectionResponse : MangaDexResponse
{
public List<MangaDexEntity> Data { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexEntity
{
public required Guid Id { get; set; }
public required string Type { get; set; }
public List<MangaDexEntity> Relationships { get; set; } = [];
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexEntityConverter : JsonConverter<MangaDexEntity>
{
public override MangaDexEntity? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
var type = root.GetProperty("type").GetString();
return type switch
{
"manga" => JsonSerializer.Deserialize<MangaEntity>(root.GetRawText(), options),
"author" => JsonSerializer.Deserialize<AuthorEntity>(root.GetRawText(), options),
"artist" => JsonSerializer.Deserialize<ArtistEntity>(root.GetRawText(), options),
"creator" => JsonSerializer.Deserialize<CreatorEntity>(root.GetRawText(), options),
"tag" => JsonSerializer.Deserialize<TagEntity>(root.GetRawText(), options),
"chapter" => JsonSerializer.Deserialize<ChapterEntity>(root.GetRawText(), options),
"scanlation_group" => JsonSerializer.Deserialize<ScanlationGroupEntity>(root.GetRawText(), options),
"cover_art" => JsonSerializer.Deserialize<CoverArtEntity>(root.GetRawText(), options),
_ => throw new NotSupportedException($"Unknown type '{type}'")
};
}
public override void Write(Utf8JsonWriter writer, MangaDexEntity value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, (object)value, options);
}
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexEntityResponse : MangaDexResponse
{
public MangaDexEntity? Data { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexResponse
{
public required string Result { get; set; }
public required string Response { get; set; }
}

View File

@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaDexResponseConverter : JsonConverter<MangaDexResponse>
{
public override MangaDexResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using JsonDocument doc = JsonDocument.ParseValue(ref reader);
JsonElement root = doc.RootElement;
string result = root.GetProperty("result").GetString() ?? "fail";
string response = root.GetProperty("response").GetString() ?? "unknown";
JsonElement dataProperty = root.GetProperty("data");
if (response == "collection" && dataProperty.ValueKind == JsonValueKind.Array)
{
MangaDexCollectionResponse collectionResponse = new()
{
Result = result,
Response = response,
Data = []
};
foreach (var item in dataProperty.EnumerateArray())
{
MangaDexEntity? entity = JsonSerializer.Deserialize<MangaDexEntity>(item.GetRawText(), options);
if (entity != null)
collectionResponse.Data.Add(entity);
}
return collectionResponse;
}
else if (response == "entity" && dataProperty.ValueKind == JsonValueKind.Object)
{
MangaDexEntityResponse entityResponse = new()
{
Result = result,
Response = response,
Data = JsonSerializer.Deserialize<MangaDexEntity>(dataProperty.GetRawText(), options)
};
return entityResponse;
}
else
{
return new()
{
Result = result,
Response = response
};
}
}
public override void Write(Utf8JsonWriter writer, MangaDexResponse value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, (object)value, options);
}
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class MangaEntity : MangaDexEntity
{
public required MangaAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class PersonAttributes
{
public string? ImageUrl { get; set; }
public Dictionary<string, string> Biography { get; set; } = [];
public string? Twitter { get; set; }
public string? Pixiv { get; set; }
public string? MelonBook { get; set; }
public string? Fanbox { get; set; }
public string? Booth { get; set; }
public string? Namicomi { get; set; }
public string? NicoVideo { get; set; }
public string? Skeb { get; set; }
public string? Fantia { get; set; }
public string? Tumblr { get; set; }
public string? Youtube { get; set; }
public string? Weibo { get; set; }
public string? Naver { get; set; }
public string? Website { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int Version { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ScanlationGroupAttributes
{
public string Name { get; set; } = default!;
public string? Website { get; set; }
// etc...
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class ScanlationGroupEntity : MangaDexEntity
{
public required ScanlationGroupAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class TagAttributes
{
public Dictionary<string, string> Name { get; set; } = [];
public Dictionary<string, string> Description { get; set; } = [];
public required string Group { get; set; }
public int Version { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MangaReader.Core.Sources.MangaDex.Api;
public class TagEntity : MangaDexEntity
{
public required TagAttributes Attributes { get; set; }
}

View File

@@ -0,0 +1,36 @@
using MangaReader.Core.HttpService;
using MangaReader.Core.Metadata;
namespace MangaReader.Core.Sources.MangaDex.Metadata;
//public class MangaDexMetadataProvider(IHttpService httpService) : IMangaMetadataProvider, IMangaSourceComponent
//{
// public string SourceId => "MangaDex";
// public async Task<SourceManga> GetManga(string url)
// {
// Guid mangaGuid = GetSourceMangaGuid(url);
// await GetSomething(mangaGuid);
// throw new NotImplementedException();
// }
// private static Guid GetSourceMangaGuid(string url)
// {
// string[] parts = url.Split('/');
// if (parts.Length < 5 || Guid.TryParse(parts[4], out Guid mangaGuid) == false)
// {
// throw new Exception("Unable to get guid from MangaDex url: " + url);
// }
// return mangaGuid;
// }
// private async Task GetSomething(Guid mangaGuid)
// {
// // https://api.mangadex.org/manga/ee96e2b7-9af2-4864-9656-649f4d3b6fec?includes[]=artist&includes[]=author&includes[]=cover_art
// await httpService.GetStringAsync($"https://api.mangadex.org/manga/{mangaGuid}/feed?translatedLanguage[]=en&limit=96&includes[]=scanlation_group&includes[]=user&order[volume]=desc&order[chapter]=desc&offset=0&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic");
// }
//}

View File

@@ -1,10 +1,12 @@
using MangaReader.Core.HttpService; using MangaReader.Core.HttpService;
using MangaReader.Core.Sources;
using MangaReader.Core.Sources.MangaDex.Search;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace MangaReader.Core.Search.MangaDex; namespace MangaReader.Core.Search.MangaDex;
public partial class MangaDexSearchProvider(IHttpService httpService) : MangaSearchProviderBase<MangaDexSearchResult>(httpService) public partial class MangaDexSearchProvider(IHttpService httpService) : MangaSearchProviderBase<MangaDexSearchResult>(httpService), IMangaSourceComponent
{ {
[GeneratedRegex(@"[^a-z0-9\s-]")] [GeneratedRegex(@"[^a-z0-9\s-]")]
private static partial Regex InvalidSlugCharactersRegex(); private static partial Regex InvalidSlugCharactersRegex();
@@ -12,6 +14,8 @@ public partial class MangaDexSearchProvider(IHttpService httpService) : MangaSea
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex(); private static partial Regex WhitespaceRegex();
public string SourceId => "MangaDex";
protected override string GetSearchUrl(string keyword) protected override string GetSearchUrl(string keyword)
{ {
string normalizedKeyword = keyword.ToLowerInvariant().Normalize(NormalizationForm.FormD); string normalizedKeyword = keyword.ToLowerInvariant().Normalize(NormalizationForm.FormD);
@@ -30,7 +34,6 @@ public partial class MangaDexSearchProvider(IHttpService httpService) : MangaSea
MangaSearchResult mangaSearchResult = new() MangaSearchResult mangaSearchResult = new()
{ {
Source = "MangaDex",
Title = title, Title = title,
Url = $"https://mangadex.org/title/{searchResultData.Id}/{slug}", Url = $"https://mangadex.org/title/{searchResultData.Id}/{slug}",
Thumbnail = GetThumbnail(searchResultData) Thumbnail = GetThumbnail(searchResultData)

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.MangaDex; namespace MangaReader.Core.Sources.MangaDex.Search;
public class MangaDexSearchResult public class MangaDexSearchResult
{ {

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.MangaDex; namespace MangaReader.Core.Sources.MangaDex.Search;
public class MangaDexSearchResultData public class MangaDexSearchResultData
{ {

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.MangaDex; namespace MangaReader.Core.Sources.MangaDex.Search;
public class MangaDexSearchResultDataAttributes public class MangaDexSearchResultDataAttributes
{ {

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.MangaDex; namespace MangaReader.Core.Sources.MangaDex.Search;
public class MangaDexSearchResultDataRelationship public class MangaDexSearchResultDataRelationship
{ {

View File

@@ -0,0 +1,69 @@
using HtmlAgilityPack;
namespace MangaReader.Core.Sources.MangaNato.Metadata;
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<HtmlNode> 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?.FirstOrDefault();
if (VariationsTableValueNodes != null && VariationsTableValueNodes.Count >= 3)
{
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']");
if (StoryInfoRightExtentValueNodes != null && StoryInfoRightExtentValueNodes.Count >= 2)
{
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']");
}
}

View File

@@ -1,8 +1,9 @@
using HtmlAgilityPack; using HtmlAgilityPack;
using MangaReader.Core.Metadata;
using System.Text; using System.Text;
using System.Web; using System.Web;
namespace MangaReader.Core.WebCrawlers.MangaNato; namespace MangaReader.Core.Sources.MangaNato.Metadata;
public class MangaNatoWebCrawler : MangaWebCrawler public class MangaNatoWebCrawler : MangaWebCrawler
{ {
@@ -13,14 +14,14 @@ public class MangaNatoWebCrawler : MangaWebCrawler
SourceManga manga = new() SourceManga manga = new()
{ {
Title = node.TitleNode.InnerText, Title = node.TitleNode?.InnerText ?? string.Empty,
AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode), AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode),
Authors = GetAuthors(node.AuthorsNode), Authors = GetAuthors(node.AuthorsNode),
Status = GetStatus(node.StatusNode), Status = GetStatus(node.StatusNode),
Genres = GetGenres(node.GenresNode), Genres = GetGenres(node.GenresNode),
UpdateDate = GetUpdateDate(node.UpdateDateNode), UpdateDate = GetUpdateDate(node.UpdateDateNode),
RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode), RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode),
Votes = int.Parse(node.VotesNode.InnerText), Votes = node.VotesNode != null ? int.Parse(node.VotesNode.InnerText) : 0,
Views = GetViews(node.ViewsNode), Views = GetViews(node.ViewsNode),
Description = GetTextFromNodes(node.StoryDescriptionTextNodes), Description = GetTextFromNodes(node.StoryDescriptionTextNodes),
Chapters = GetChapters(node.ChapterNodes) Chapters = GetChapters(node.ChapterNodes)
@@ -29,19 +30,25 @@ public class MangaNatoWebCrawler : MangaWebCrawler
return manga; return manga;
} }
private static List<string> GetAlternateTitles(HtmlNode node) private static List<string> GetAlternateTitles(HtmlNode? node)
{ {
return node.InnerText.Split(';').Select(x => x.Trim()).ToList(); if (node == null)
return [];
return [.. node.InnerText.Split(';').Select(x => x.Trim())];
} }
private static List<string> GetAuthors(HtmlNode node) private static List<string> GetAuthors(HtmlNode? node)
{ {
return node.InnerText.Split('-').Select(x => x.Trim()).ToList(); if (node == null)
return [];
return [.. node.InnerText.Split('-').Select(x => x.Trim())];
} }
private static MangaStatus GetStatus(HtmlNode node) private static MangaStatus GetStatus(HtmlNode? node)
{ {
return node.InnerText switch return node?.InnerText switch
{ {
"Ongoing" => MangaStatus.Ongoing, "Ongoing" => MangaStatus.Ongoing,
"Completed" => MangaStatus.Complete, "Completed" => MangaStatus.Complete,
@@ -49,22 +56,31 @@ public class MangaNatoWebCrawler : MangaWebCrawler
}; };
} }
private static List<string> GetGenres(HtmlNode node) private static List<string> GetGenres(HtmlNode? node)
{ {
return node.InnerText.Split('-').Select(x => x.Trim()).ToList(); if (node == null)
return [];
return [.. node.InnerText.Split('-').Select(x => x.Trim())];
} }
private static DateTime GetUpdateDate(HtmlNode node) private static DateTime? GetUpdateDate(HtmlNode? node)
{ {
List<string> dateAndTime = node.InnerText.Split('-').Select(x => x.Trim()).ToList(); if (node == null)
return null;
List<string> dateAndTime = [.. node.InnerText.Split('-').Select(x => x.Trim())];
DateOnly date = DateOnly.Parse(dateAndTime[0]); DateOnly date = DateOnly.Parse(dateAndTime[0]);
TimeOnly time = TimeOnly.Parse(dateAndTime[1]); TimeOnly time = TimeOnly.Parse(dateAndTime[1]);
return date.ToDateTime(time); return date.ToDateTime(time);
} }
private static long GetViews(HtmlNode node) private static long GetViews(HtmlNode? node)
{ {
if (node == null)
return 0;
string text = node.InnerText; string text = node.InnerText;
if (int.TryParse(text, out int number)) if (int.TryParse(text, out int number))
@@ -93,31 +109,37 @@ public class MangaNatoWebCrawler : MangaWebCrawler
}; };
} }
private static int GetRatingPercent(HtmlNode averageNode, HtmlNode bestNode) private static int GetRatingPercent(HtmlNode? averageNode, HtmlNode? bestNode)
{ {
if (averageNode == null || bestNode == null)
return 0;
double average = Convert.ToDouble(averageNode.InnerText); double average = Convert.ToDouble(averageNode.InnerText);
double best = Convert.ToDouble(bestNode.InnerText); double best = Convert.ToDouble(bestNode.InnerText);
return (int)Math.Round(average / best * 100); return (int)Math.Round(average / best * 100);
} }
private static List<SourceMangaChapter> GetChapters(HtmlNodeCollection chapterNodes) private static List<SourceMangaChapter> GetChapters(HtmlNodeCollection? chapterNodes)
{ {
List<SourceMangaChapter> chapters = []; List<SourceMangaChapter> chapters = [];
if (chapterNodes == null)
return chapters;
foreach (var node in chapterNodes) foreach (var node in chapterNodes)
{ {
HtmlNode chapterNameNode = node.SelectSingleNode(".//a[contains(@class, 'chapter-name')]"); HtmlNode? chapterNameNode = node.SelectSingleNode(".//a[contains(@class, 'chapter-name')]");
HtmlNode chapterViewNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-view')]"); HtmlNode? chapterViewNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-view')]");
HtmlNode chapterTimeNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-time')]"); HtmlNode? chapterTimeNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-time')]");
SourceMangaChapter chapter = new() SourceMangaChapter chapter = new()
{ {
Number = GetChapterNumber(chapterNameNode), Number = GetChapterNumber(chapterNameNode),
Name = chapterNameNode.InnerText, Name = chapterNameNode?.InnerText ?? string.Empty,
Url = chapterNameNode.Attributes["href"].Value, Url = chapterNameNode?.Attributes["href"].Value ?? string.Empty,
Views = GetViews(chapterViewNode), Views = GetViews(chapterViewNode),
UploadDate = DateTime.Parse(chapterTimeNode.Attributes["title"].Value) UploadDate = chapterTimeNode != null ? DateTime.Parse(chapterTimeNode.Attributes["title"].Value) : null
}; };
chapters.Add(chapter); chapters.Add(chapter);
@@ -126,8 +148,11 @@ public class MangaNatoWebCrawler : MangaWebCrawler
return chapters; return chapters;
} }
private static float GetChapterNumber(HtmlNode chapterNameNode) private static float GetChapterNumber(HtmlNode? chapterNameNode)
{ {
if (chapterNameNode == null)
return 0;
string url = chapterNameNode.Attributes["href"].Value; string url = chapterNameNode.Attributes["href"].Value;
int index = url.IndexOf("/chapter-"); int index = url.IndexOf("/chapter-");

View File

@@ -1,6 +1,6 @@
using HtmlAgilityPack; using HtmlAgilityPack;
namespace MangaReader.Core.WebCrawlers.NatoManga; namespace MangaReader.Core.Sources.NatoManga.Metadata;
public class NatoMangaHtmlDocument public class NatoMangaHtmlDocument
{ {

View File

@@ -1,11 +1,12 @@
using HtmlAgilityPack; using HtmlAgilityPack;
using System.Text; using MangaReader.Core.Metadata;
using System.Web;
namespace MangaReader.Core.WebCrawlers.NatoManga; namespace MangaReader.Core.Sources.NatoManga.Metadata;
public class NatoMangaWebCrawler : MangaWebCrawler public class NatoMangaWebCrawler : MangaWebCrawler, IMangaSourceComponent
{ {
public string SourceId => "NatoManga";
public override SourceManga GetManga(string url) public override SourceManga GetManga(string url)
{ {
HtmlDocument document = GetHtmlDocument(url); HtmlDocument document = GetHtmlDocument(url);
@@ -14,47 +15,19 @@ public class NatoMangaWebCrawler : MangaWebCrawler
SourceManga manga = new() SourceManga manga = new()
{ {
Title = node.TitleNode?.InnerText ?? string.Empty, Title = node.TitleNode?.InnerText ?? string.Empty,
//AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode),
//Authors = GetAuthors(node.AuthorsNode),
//Status = GetStatus(node.StatusNode),
Genres = GetGenres(node.GenresNode), 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) Chapters = GetChapters(node.ChapterNodes)
}; };
return manga; return manga;
} }
private static List<string> GetAlternateTitles(HtmlNode node)
{
return node.InnerText.Split(';').Select(x => x.Trim()).ToList();
}
private static List<string> 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<string> GetGenres(HtmlNode? node) private static List<string> GetGenres(HtmlNode? node)
{ {
if (node == null) if (node == null)
return []; return [];
HtmlNodeCollection genreNodes = node.SelectNodes(".//a"); HtmlNodeCollection? genreNodes = node.SelectNodes(".//a");
if (genreNodes == null) if (genreNodes == null)
return []; return [];
@@ -62,15 +35,6 @@ public class NatoMangaWebCrawler : MangaWebCrawler
return [.. genreNodes.Select(genreNode => genreNode.InnerText.Trim())]; return [.. genreNodes.Select(genreNode => genreNode.InnerText.Trim())];
} }
private static DateTime GetUpdateDate(HtmlNode node)
{
List<string> 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) private static long GetViews(HtmlNode node)
{ {
string text = node.InnerText.Trim(); string text = node.InnerText.Trim();
@@ -108,14 +72,6 @@ public class NatoMangaWebCrawler : MangaWebCrawler
}; };
} }
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<SourceMangaChapter> GetChapters(HtmlNodeCollection? chapterNodes) private static List<SourceMangaChapter> GetChapters(HtmlNodeCollection? chapterNodes)
{ {
List<SourceMangaChapter> chapters = []; List<SourceMangaChapter> chapters = [];
@@ -164,23 +120,4 @@ public class NatoMangaWebCrawler : MangaWebCrawler
return float.Parse(chapterNumber); return float.Parse(chapterNumber);
} }
private static string GetTextFromNodes(List<HtmlNode> 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();
}
} }

View File

@@ -1,12 +1,15 @@
using MangaReader.Core.HttpService; using MangaReader.Core.HttpService;
using MangaReader.Core.Search;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace MangaReader.Core.Search.NatoManga; namespace MangaReader.Core.Sources.NatoManga.Search;
public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProviderBase<NatoMangaSearchResult[]>(httpService) public partial class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProviderBase<NatoMangaSearchResult[]>(httpService), IMangaSourceComponent
{ {
public string SourceId => "NatoManga";
protected override string GetSearchUrl(string keyword) protected override string GetSearchUrl(string keyword)
{ {
string formattedSeachWord = GetFormattedSearchWord(keyword); string formattedSeachWord = GetFormattedSearchWord(keyword);
@@ -33,10 +36,10 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
} }
// Replace non-alphanumeric characters with underscores // Replace non-alphanumeric characters with underscores
string cleaned = Regex.Replace(sb.ToString(), @"[^a-z0-9]+", "_"); string cleaned = NonAlphaNumericCharactersRegex().Replace(sb.ToString(), "_");
// Trim and collapse underscores // Trim and collapse underscores
cleaned = Regex.Replace(cleaned, "_{2,}", "_").Trim('_'); cleaned = ExtendedUnderscoresRegex().Replace(cleaned, "_").Trim('_');
return cleaned; return cleaned;
} }
@@ -46,7 +49,6 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
IEnumerable<MangaSearchResult> mangaSearchResults = searchResult.Select(searchResult => IEnumerable<MangaSearchResult> mangaSearchResults = searchResult.Select(searchResult =>
new MangaSearchResult() new MangaSearchResult()
{ {
Source = "NatoManga",
Title = searchResult.Name, Title = searchResult.Name,
Thumbnail = searchResult.Thumb, Thumbnail = searchResult.Thumb,
Url = searchResult.Url Url = searchResult.Url
@@ -54,4 +56,10 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
return [.. mangaSearchResults]; return [.. mangaSearchResults];
} }
[GeneratedRegex(@"[^a-z0-9]+")]
private static partial Regex NonAlphaNumericCharactersRegex();
[GeneratedRegex("_{2,}")]
private static partial Regex ExtendedUnderscoresRegex();
} }

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.NatoManga; namespace MangaReader.Core.Sources.NatoManga.Search;
public record NatoMangaSearchResult public record NatoMangaSearchResult
{ {

View File

@@ -1,6 +0,0 @@
namespace MangaReader.Core.WebCrawlers;
public interface IMangaWebCrawler
{
SourceManga GetManga(string url);
}

View File

@@ -1,61 +0,0 @@
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<HtmlNode> 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']");
}
}

View File

@@ -11,6 +11,8 @@
<ItemGroup> <ItemGroup>
<None Remove="Search\MangaDex\SampleSearchResult.json" /> <None Remove="Search\MangaDex\SampleSearchResult.json" />
<None Remove="WebCrawlers\MangaDex\MetadataSample-Feed.json" />
<None Remove="WebCrawlers\MangaDex\MetadataSample.json" />
<None Remove="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm" /> <None Remove="WebCrawlers\Samples\MangaNato - Please Go Home, Akutsu-San!.htm" />
<None Remove="Search\NatoManga\SampleSearchResult.json" /> <None Remove="Search\NatoManga\SampleSearchResult.json" />
</ItemGroup> </ItemGroup>
@@ -27,6 +29,8 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Search\MangaDex\SampleSearchResult.json" /> <EmbeddedResource Include="Search\MangaDex\SampleSearchResult.json" />
<EmbeddedResource Include="Search\NatoManga\SampleSearchResult.json" /> <EmbeddedResource Include="Search\NatoManga\SampleSearchResult.json" />
<EmbeddedResource Include="WebCrawlers\MangaDex\MetadataSample-Feed.json" />
<EmbeddedResource Include="WebCrawlers\MangaDex\MetadataSample.json" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -36,7 +36,7 @@ public class MangaDexSearchTests
IHttpService httpService = Substitute.For<IHttpService>(); IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>()) httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson)); .Returns(Task.FromResult(searchResultJson));
MangaDexSearchProvider searchProvider = new(httpService); MangaDexSearchProvider searchProvider = new(httpService);

View File

@@ -1,6 +1,6 @@
using MangaReader.Core.HttpService; using MangaReader.Core.HttpService;
using MangaReader.Core.Search; using MangaReader.Core.Search;
using MangaReader.Core.Search.NatoManga; using MangaReader.Core.Sources.NatoManga.Search;
using MangaReader.Tests.Utilities; using MangaReader.Tests.Utilities;
using NSubstitute; using NSubstitute;
using Shouldly; using Shouldly;
@@ -31,7 +31,7 @@ public class NatoMangaWebSearchTests
IHttpService httpService = Substitute.For<IHttpService>(); IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>()) httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson)); .Returns(Task.FromResult(searchResultJson));
NatoMangaSearchProvider searchProvider = new(httpService); NatoMangaSearchProvider searchProvider = new(httpService);

View File

@@ -0,0 +1,51 @@
using MangaReader.Core.HttpService;
using MangaReader.Core.Sources.MangaDex.Api;
using MangaReader.Tests.Utilities;
using NSubstitute;
using Shouldly;
namespace MangaReader.Tests.WebCrawlers.MangaDex;
public class MangaDexMetadataTests
{
[Fact]
public async Task Get_Manga_Metadata()
{
string resourceName = "MangaReader.Tests.WebCrawlers.MangaDex.MetadataSample.json";
string searchResultJson = await ResourceHelper.ReadJsonResourceAsync(resourceName);
IHttpService httpService = Substitute.For<IHttpService>();
httpService.GetStringAsync(Arg.Any<string>(), CancellationToken.None)
.Returns(Task.FromResult(searchResultJson));
MangaDexClient mangaDexClient = new(httpService);
MangaDexResponse mangaDexResponse = await mangaDexClient.GetMangaAsync(Guid.NewGuid(), CancellationToken.None);
mangaDexResponse.Response.ShouldBe("entity");
mangaDexResponse.ShouldBeOfType<MangaDexEntityResponse>();
MangaDexEntityResponse? mangaDexEntityResponse = mangaDexResponse as MangaDexEntityResponse;
mangaDexEntityResponse.ShouldNotBeNull();
mangaDexEntityResponse.Data.ShouldNotBeNull();
mangaDexEntityResponse.Data.ShouldBeOfType<MangaEntity>();
MangaEntity? mangaEntity = mangaDexEntityResponse.Data as MangaEntity;
mangaEntity.ShouldNotBeNull();
mangaEntity.Attributes.Title.ShouldContainKey("en");
mangaEntity.Attributes.Title["en"].ShouldBe("Gals Cant Be Kind to Otaku!?");
mangaEntity.Attributes.Description.ShouldContainKey("en");
mangaEntity.Attributes.Description["en"].ShouldBe("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...");
mangaEntity.Attributes.Tags.Count.ShouldBe(5);
mangaEntity.Attributes.Tags[0].Attributes.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[0].Attributes.Name["en"].ShouldBe("Romance");
mangaEntity.Attributes.Tags[1].Attributes.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[1].Attributes.Name["en"].ShouldBe("Comedy");
mangaEntity.Attributes.Tags[2].Attributes.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[2].Attributes.Name["en"].ShouldBe("School Life");
mangaEntity.Attributes.Tags[3].Attributes.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[3].Attributes.Name["en"].ShouldBe("Slice of Life");
mangaEntity.Attributes.Tags[4].Attributes.Name.ShouldContainKey("en");
mangaEntity.Attributes.Tags[4].Attributes.Name["en"].ShouldBe("Gyaru");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
{
"result": "ok",
"response": "entity",
"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\u0027t Be Kind to Otaku!?" },
{ "en": "Gals Can\u0027t 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 \u0022anime for girls\u0022 and can\u0027t 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 \u0022anime para chicas\u0022 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 \u0022animes para garotinhas\u0022 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)\u0026order=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",
"attributes": {
"name": "Norishiro-chan",
"imageUrl": null,
"biography": { "en": "Name (in native language)\n\u306e\u308a\u3057\u308d\u3061\u3083\u3093" },
"twitter": "https:\/\/twitter.com\/norishirochan",
"pixiv": "https:\/\/www.pixiv.net\/en\/users\/821541",
"melonBook": null,
"fanBox": null,
"booth": null,
"namicomi": null,
"nicoVideo": null,
"skeb": null,
"fantia": null,
"tumblr": null,
"youtube": null,
"weibo": null,
"naver": null,
"website": null,
"createdAt": "2021-10-10T03:41:31+00:00",
"updatedAt": "2024-08-13T17:35:37+00:00",
"version": 4
}
},
{
"id": "767b8851-6060-478a-a23e-f819fec0fbf2",
"type": "artist",
"attributes": {
"name": "Sakana Uozimi",
"imageUrl": null,
"biography": { "en": "Name (in native language)\n\u9b5a\u4f4f\u3055\u304b\u306a" },
"twitter": "https:\/\/twitter.com\/namekyunta",
"pixiv": "https:\/\/www.pixiv.net\/en\/users\/468508",
"melonBook": null,
"fanBox": null,
"booth": null,
"namicomi": null,
"nicoVideo": null,
"skeb": null,
"fantia": null,
"tumblr": null,
"youtube": null,
"weibo": null,
"naver": null,
"website": "https:\/\/galleria.emotionflow.com\/11070\/",
"createdAt": "2022-03-08T07:36:27+00:00",
"updatedAt": "2023-11-27T23:10:41+00:00",
"version": 3
}
},
{
"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"
}
]
}
}

View File

@@ -1,4 +1,4 @@
using MangaReader.Core.WebCrawlers.NatoManga; using MangaReader.Core.Sources.NatoManga.Metadata;
using Shouldly; using Shouldly;
namespace MangaReader.Tests.WebCrawlers.NatoManga; namespace MangaReader.Tests.WebCrawlers.NatoManga;

View File

@@ -1,5 +1,5 @@
using MangaReader.Core.WebCrawlers; using MangaReader.Core.Metadata;
using MangaReader.Core.WebCrawlers.MangaNato; using MangaReader.Core.Sources.MangaNato.Metadata;
using Shouldly; using Shouldly;
namespace MangaReader.Tests.WebCrawlers; namespace MangaReader.Tests.WebCrawlers;