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.MangaDex;
using MangaReader.Core.Search.NatoManga;
using MangaReader.Core.Sources.NatoManga.Search;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
using System.Collections.Generic;

View File

@@ -2,5 +2,6 @@
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
{
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
{

View File

@@ -1,8 +1,8 @@
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
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; }

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace MangaReader.Core.Search.MangaDex;
namespace MangaReader.Core.Sources.MangaDex.Search;
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 MangaReader.Core.Metadata;
using System.Text;
using System.Web;
namespace MangaReader.Core.WebCrawlers.MangaNato;
namespace MangaReader.Core.Sources.MangaNato.Metadata;
public class MangaNatoWebCrawler : MangaWebCrawler
{
@@ -13,14 +14,14 @@ public class MangaNatoWebCrawler : MangaWebCrawler
SourceManga manga = new()
{
Title = node.TitleNode.InnerText,
Title = node.TitleNode?.InnerText ?? string.Empty,
AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode),
Authors = GetAuthors(node.AuthorsNode),
Status = GetStatus(node.StatusNode),
Genres = GetGenres(node.GenresNode),
UpdateDate = GetUpdateDate(node.UpdateDateNode),
RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode),
Votes = int.Parse(node.VotesNode.InnerText),
Votes = node.VotesNode != null ? int.Parse(node.VotesNode.InnerText) : 0,
Views = GetViews(node.ViewsNode),
Description = GetTextFromNodes(node.StoryDescriptionTextNodes),
Chapters = GetChapters(node.ChapterNodes)
@@ -29,19 +30,25 @@ public class MangaNatoWebCrawler : MangaWebCrawler
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,
"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]);
TimeOnly time = TimeOnly.Parse(dateAndTime[1]);
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;
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 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 = [];
if (chapterNodes == null)
return chapters;
foreach (var node in chapterNodes)
{
HtmlNode chapterNameNode = node.SelectSingleNode(".//a[contains(@class, 'chapter-name')]");
HtmlNode chapterViewNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-view')]");
HtmlNode chapterTimeNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-time')]");
HtmlNode? chapterNameNode = node.SelectSingleNode(".//a[contains(@class, 'chapter-name')]");
HtmlNode? chapterViewNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-view')]");
HtmlNode? chapterTimeNode = node.SelectSingleNode(".//span[contains(@class, 'chapter-time')]");
SourceMangaChapter chapter = new()
{
Number = GetChapterNumber(chapterNameNode),
Name = chapterNameNode.InnerText,
Url = chapterNameNode.Attributes["href"].Value,
Name = chapterNameNode?.InnerText ?? string.Empty,
Url = chapterNameNode?.Attributes["href"].Value ?? string.Empty,
Views = GetViews(chapterViewNode),
UploadDate = DateTime.Parse(chapterTimeNode.Attributes["title"].Value)
UploadDate = chapterTimeNode != null ? DateTime.Parse(chapterTimeNode.Attributes["title"].Value) : null
};
chapters.Add(chapter);
@@ -126,8 +148,11 @@ public class MangaNatoWebCrawler : MangaWebCrawler
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;
int index = url.IndexOf("/chapter-");

View File

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

View File

@@ -1,11 +1,12 @@
using HtmlAgilityPack;
using System.Text;
using System.Web;
using MangaReader.Core.Metadata;
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)
{
HtmlDocument document = GetHtmlDocument(url);
@@ -14,47 +15,19 @@ public class NatoMangaWebCrawler : MangaWebCrawler
SourceManga manga = new()
{
Title = node.TitleNode?.InnerText ?? string.Empty,
//AlternateTitles = GetAlternateTitles(node.AlternateTitlesNode),
//Authors = GetAuthors(node.AuthorsNode),
//Status = GetStatus(node.StatusNode),
Genres = GetGenres(node.GenresNode),
//UpdateDate = GetUpdateDate(node.UpdateDateNode),
//RatingPercent = GetRatingPercent(node.AverageRatingNode, node.BestRatingNode),
//Votes = int.Parse(node.VotesNode.InnerText),
//Views = GetViews(node.ViewsNode),
//Description = GetTextFromNodes(node.StoryDescriptionTextNodes),
Chapters = GetChapters(node.ChapterNodes)
};
return manga;
}
private static List<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)
{
if (node == null)
return [];
HtmlNodeCollection genreNodes = node.SelectNodes(".//a");
HtmlNodeCollection? genreNodes = node.SelectNodes(".//a");
if (genreNodes == null)
return [];
@@ -62,15 +35,6 @@ public class NatoMangaWebCrawler : MangaWebCrawler
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)
{
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)
{
List<SourceMangaChapter> chapters = [];
@@ -164,23 +120,4 @@ public class NatoMangaWebCrawler : MangaWebCrawler
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.Search;
using System.Globalization;
using System.Text;
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)
{
string formattedSeachWord = GetFormattedSearchWord(keyword);
@@ -33,10 +36,10 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
}
// 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
cleaned = Regex.Replace(cleaned, "_{2,}", "_").Trim('_');
cleaned = ExtendedUnderscoresRegex().Replace(cleaned, "_").Trim('_');
return cleaned;
}
@@ -46,7 +49,6 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
IEnumerable<MangaSearchResult> mangaSearchResults = searchResult.Select(searchResult =>
new MangaSearchResult()
{
Source = "NatoManga",
Title = searchResult.Name,
Thumbnail = searchResult.Thumb,
Url = searchResult.Url
@@ -54,4 +56,10 @@ public class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProv
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
{

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>
<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="Search\NatoManga\SampleSearchResult.json" />
</ItemGroup>
@@ -27,6 +29,8 @@
<ItemGroup>
<EmbeddedResource Include="Search\MangaDex\SampleSearchResult.json" />
<EmbeddedResource Include="Search\NatoManga\SampleSearchResult.json" />
<EmbeddedResource Include="WebCrawlers\MangaDex\MetadataSample-Feed.json" />
<EmbeddedResource Include="WebCrawlers\MangaDex\MetadataSample.json" />
</ItemGroup>
<ItemGroup>

View File

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

View File

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

View File

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