Added MangaDex Api. Updated project structure.
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
public interface IHttpService
|
public interface IHttpService
|
||||||
{
|
{
|
||||||
Task<string> GetStringAsync(string url);
|
Task<string> GetStringAsync(string url, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
6
MangaReader.Core/Metadata/IMangaMetadataProvider.cs
Normal file
6
MangaReader.Core/Metadata/IMangaMetadataProvider.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Metadata;
|
||||||
|
|
||||||
|
public interface IMangaMetadataProvider
|
||||||
|
{
|
||||||
|
SourceManga GetManga(string url);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.WebCrawlers;
|
namespace MangaReader.Core.Metadata;
|
||||||
|
|
||||||
public enum MangaStatus
|
public enum MangaStatus
|
||||||
{
|
{
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.WebCrawlers;
|
namespace MangaReader.Core.Metadata;
|
||||||
|
|
||||||
public class SourceManga
|
public class SourceManga
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.WebCrawlers;
|
namespace MangaReader.Core.Metadata;
|
||||||
|
|
||||||
public class SourceMangaChapter
|
public class SourceMangaChapter
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using MangaReader.Core.WebCrawlers;
|
using MangaReader.Core.Metadata;
|
||||||
|
|
||||||
namespace MangaReader.Core.Pipeline;
|
namespace MangaReader.Core.Pipeline;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
6
MangaReader.Core/Sources/IMangaSourceComponent.cs
Normal file
6
MangaReader.Core/Sources/IMangaSourceComponent.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources;
|
||||||
|
|
||||||
|
public interface IMangaSourceComponent
|
||||||
|
{
|
||||||
|
string SourceId { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class ArtistAttributes : PersonAttributes
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/ArtistEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/ArtistEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class ArtistEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required ArtistAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class AuthorAttributes : PersonAttributes
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/AuthorEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/AuthorEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class AuthorEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required AuthorAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/ChapterEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/ChapterEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class ChapterEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required ChapterAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
12
MangaReader.Core/Sources/MangaDex/Api/CoverArtAttributes.cs
Normal file
12
MangaReader.Core/Sources/MangaDex/Api/CoverArtAttributes.cs
Normal 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; }
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/CoverArtEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/CoverArtEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class CoverArtEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required CoverArtAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/CreatorEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/CreatorEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class CreatorEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
7
MangaReader.Core/Sources/MangaDex/Api/IMangaDexClient.cs
Normal file
7
MangaReader.Core/Sources/MangaDex/Api/IMangaDexClient.cs
Normal 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);
|
||||||
|
}
|
||||||
9
MangaReader.Core/Sources/MangaDex/Api/MangaAttributes.cs
Normal file
9
MangaReader.Core/Sources/MangaDex/Api/MangaAttributes.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
39
MangaReader.Core/Sources/MangaDex/Api/MangaDexClient.cs
Normal file
39
MangaReader.Core/Sources/MangaDex/Api/MangaDexClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class MangaDexCollectionResponse : MangaDexResponse
|
||||||
|
{
|
||||||
|
public List<MangaDexEntity> Data { get; set; } = [];
|
||||||
|
}
|
||||||
8
MangaReader.Core/Sources/MangaDex/Api/MangaDexEntity.cs
Normal file
8
MangaReader.Core/Sources/MangaDex/Api/MangaDexEntity.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class MangaDexEntityResponse : MangaDexResponse
|
||||||
|
{
|
||||||
|
public MangaDexEntity? Data { get; set; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/MangaEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/MangaEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class MangaEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required MangaAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
24
MangaReader.Core/Sources/MangaDex/Api/PersonAttributes.cs
Normal file
24
MangaReader.Core/Sources/MangaDex/Api/PersonAttributes.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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...
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class ScanlationGroupEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required ScanlationGroupAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
9
MangaReader.Core/Sources/MangaDex/Api/TagAttributes.cs
Normal file
9
MangaReader.Core/Sources/MangaDex/Api/TagAttributes.cs
Normal 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; }
|
||||||
|
}
|
||||||
6
MangaReader.Core/Sources/MangaDex/Api/TagEntity.cs
Normal file
6
MangaReader.Core/Sources/MangaDex/Api/TagEntity.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MangaReader.Core.Sources.MangaDex.Api;
|
||||||
|
|
||||||
|
public class TagEntity : MangaDexEntity
|
||||||
|
{
|
||||||
|
public required TagAttributes Attributes { get; set; }
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.Search.MangaDex;
|
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||||
|
|
||||||
public class MangaDexSearchResult
|
public class MangaDexSearchResult
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.Search.MangaDex;
|
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||||
|
|
||||||
public class MangaDexSearchResultData
|
public class MangaDexSearchResultData
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.Search.MangaDex;
|
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||||
|
|
||||||
public class MangaDexSearchResultDataAttributes
|
public class MangaDexSearchResultDataAttributes
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.Search.MangaDex;
|
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||||
|
|
||||||
public class MangaDexSearchResultDataRelationship
|
public class MangaDexSearchResultDataRelationship
|
||||||
{
|
{
|
||||||
@@ -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']");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-");
|
||||||
|
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace MangaReader.Core.Search.NatoManga;
|
namespace MangaReader.Core.Sources.NatoManga.Search;
|
||||||
|
|
||||||
public record NatoMangaSearchResult
|
public record NatoMangaSearchResult
|
||||||
{
|
{
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace MangaReader.Core.WebCrawlers;
|
|
||||||
|
|
||||||
public interface IMangaWebCrawler
|
|
||||||
{
|
|
||||||
SourceManga GetManga(string url);
|
|
||||||
}
|
|
||||||
@@ -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']");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 Can’t 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
4165
MangaReader.Tests/WebCrawlers/MangaDex/MetadataSample-Feed.json
Normal file
4165
MangaReader.Tests/WebCrawlers/MangaDex/MetadataSample-Feed.json
Normal file
File diff suppressed because it is too large
Load Diff
185
MangaReader.Tests/WebCrawlers/MangaDex/MetadataSample.json
Normal file
185
MangaReader.Tests/WebCrawlers/MangaDex/MetadataSample.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user