Added MangaDex Api. Updated project structure.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
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
|
||||
{
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.WebCrawlers;
|
||||
namespace MangaReader.Core.Metadata;
|
||||
|
||||
public class SourceManga
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.WebCrawlers;
|
||||
namespace MangaReader.Core.Metadata;
|
||||
|
||||
public class SourceMangaChapter
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
using MangaReader.Core.WebCrawlers;
|
||||
using MangaReader.Core.Metadata;
|
||||
|
||||
namespace MangaReader.Core.Pipeline;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using MangaReader.Core.Data;
|
||||
using MangaReader.Core.WebCrawlers;
|
||||
using MangaReader.Core.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
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.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)
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.Search.MangaDex;
|
||||
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||
|
||||
public class MangaDexSearchResult
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.Search.MangaDex;
|
||||
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||
|
||||
public class MangaDexSearchResultData
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.Search.MangaDex;
|
||||
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||
|
||||
public class MangaDexSearchResultDataAttributes
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.Search.MangaDex;
|
||||
namespace MangaReader.Core.Sources.MangaDex.Search;
|
||||
|
||||
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 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-");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using HtmlAgilityPack;
|
||||
|
||||
namespace MangaReader.Core.WebCrawlers.NatoManga;
|
||||
namespace MangaReader.Core.Sources.NatoManga.Metadata;
|
||||
|
||||
public class NatoMangaHtmlDocument
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MangaReader.Core.Search.NatoManga;
|
||||
namespace MangaReader.Core.Sources.NatoManga.Search;
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
namespace MangaReader.Tests.WebCrawlers.NatoManga;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user