mirror of
https://github.com/C9Glax/tranga.git
synced 2025-09-10 20:08:19 +02:00
Compare commits
4 Commits
bubez81/mw
...
cuttingedg
Author | SHA1 | Date | |
---|---|---|---|
57cb48cbd0 | |||
611e8a04df | |||
6231f9a842 | |||
7f9bea00a4 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,13 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: File a bug report
|
description: File a bug report
|
||||||
title: "[It broke]: "
|
title: "[Tranga broke]: <title>"
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: What is broken?
|
label: What is broken?
|
||||||
description: What happened? How did we get here?
|
description: What happened? How did we get here?
|
||||||
placeholder: The place where you tell me what you expected to happen, and what happened instead.
|
placeholder: Tell me what you expected to happen, and what happened instead.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -18,4 +18,4 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional stuff
|
label: Additional stuff
|
||||||
description: Screenshots, anything you think might help
|
description: Screenshots, anything you think might help
|
13
.github/ISSUE_TEMPLATE/new_connector.yml
vendored
13
.github/ISSUE_TEMPLATE/new_connector.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: New Connector Request
|
name: New Connector Request
|
||||||
description: Request a new site to be added
|
description: Request a new site to be added
|
||||||
title: "[New Connector]: "
|
title: "[New Connector]: <title>"
|
||||||
labels: ["New Connector"]
|
labels: ["New Connector"]
|
||||||
body:
|
body:
|
||||||
- type: input
|
- type: input
|
||||||
@@ -9,15 +9,12 @@ body:
|
|||||||
placeholder: https://
|
placeholder: https://
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Is the Website free to access?
|
|
||||||
description: We can't support pay-to-use sites, or captcha-proxied sites as Cloudflare.
|
|
||||||
options:
|
|
||||||
- label: The Website is freely accessible.
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Anything else?
|
label: Anything else?
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
If you want implement this read [Contributing](https://github.com/C9Glax/tranga#contributing). Thank you!
|
31
.github/PULL_REQUEST_TEMPLATE/new_connector.yml
vendored
Normal file
31
.github/PULL_REQUEST_TEMPLATE/new_connector.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: New Connector Implementation
|
||||||
|
description: New Connector
|
||||||
|
title: "<title>"
|
||||||
|
labels: ["New Connector"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for contributing. Please make sure you have read [Contributing](https://github.com/C9Glax/tranga#contributing).
|
||||||
|
Just for the sake of completion:
|
||||||
|
- type: checkboxes
|
||||||
|
id: Contributing
|
||||||
|
attributes:
|
||||||
|
label: Contributing
|
||||||
|
description: I have checked
|
||||||
|
options:
|
||||||
|
- label: Formatting (if not done yet, mark this PR as draft)
|
||||||
|
required: false
|
||||||
|
- label: I have read https://github.com/C9Glax/tranga#if-you-want-to-add-a-new-website-connector
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Existing Issue
|
||||||
|
placeholder: #<Issue number>
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
validations:
|
||||||
|
required: false
|
@@ -324,7 +324,7 @@ public class MangaDex : MangaConnector
|
|||||||
{
|
{
|
||||||
string? id = jToken.Value<string>("id");
|
string? id = jToken.Value<string>("id");
|
||||||
JToken? attributes = jToken["attributes"];
|
JToken? attributes = jToken["attributes"];
|
||||||
string? chapterStr = attributes?.Value<string>("chapter");
|
string? chapterStr = attributes?.Value<string>("chapter") ?? "0";
|
||||||
string? volumeStr = attributes?.Value<string>("volume");
|
string? volumeStr = attributes?.Value<string>("volume");
|
||||||
int? volumeNumber = null;
|
int? volumeNumber = null;
|
||||||
string? title = attributes?.Value<string>("title");
|
string? title = attributes?.Value<string>("title");
|
||||||
|
@@ -1,507 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using API.MangaDownloadClients;
|
|
||||||
using API.Schema.MangaContext;
|
|
||||||
using HtmlAgilityPack;
|
|
||||||
// ReSharper disable StringLiteralTypo
|
|
||||||
|
|
||||||
namespace API.MangaConnectors;
|
|
||||||
|
|
||||||
public sealed class Mangaworld : MangaConnector
|
|
||||||
{
|
|
||||||
public Mangaworld() : base(
|
|
||||||
"Mangaworld",
|
|
||||||
["it"],
|
|
||||||
[
|
|
||||||
"mangaworld.cx","www.mangaworld.cx",
|
|
||||||
"mangaworld.bz","www.mangaworld.bz",
|
|
||||||
"mangaworld.fun","www.mangaworld.fun",
|
|
||||||
"mangaworld.ac","www.mangaworld.ac"
|
|
||||||
],
|
|
||||||
"https://www.mangaworld.cx/public/assets/seo/favicon-96x96.png?v=3"
|
|
||||||
)
|
|
||||||
{
|
|
||||||
downloadClient = new HttpDownloadClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ SEARCH ============================
|
|
||||||
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
|
|
||||||
{
|
|
||||||
Uri baseUri = new ("https://www.mangaworld.cx/");
|
|
||||||
Uri searchUrl = new (baseUri, "archive?keyword=" + Uri.EscapeDataString(mangaSearchName));
|
|
||||||
|
|
||||||
RequestResult res = downloadClient.MakeRequest(searchUrl.ToString(), RequestType.Default);
|
|
||||||
if ((int)res.statusCode < 200 || (int)res.statusCode >= 300)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
using StreamReader sr = new (res.result);
|
|
||||||
string html = sr.ReadToEnd();
|
|
||||||
|
|
||||||
HtmlDocument doc = new ();
|
|
||||||
doc.LoadHtml(html);
|
|
||||||
|
|
||||||
HtmlNodeCollection? anchors = doc.DocumentNode.SelectNodes("//a[@href and contains(@href,'/manga/')]");
|
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract Apparently it does return null. Ask AgilityPack why the return type isnt marked as such...
|
|
||||||
if (anchors is null || anchors.Count < 1)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
List<(Manga, MangaConnectorId<Manga>)> list = [];
|
|
||||||
|
|
||||||
foreach (HtmlNode a in anchors)
|
|
||||||
{
|
|
||||||
string href = a.GetAttributeValue("href", "");
|
|
||||||
if (string.IsNullOrEmpty(href))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string canonical = new Uri(baseUri, href).ToString();
|
|
||||||
|
|
||||||
(Manga, MangaConnectorId<Manga>)? manga = GetMangaFromUrl(canonical);
|
|
||||||
if(manga is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
list.Add(((Manga, MangaConnectorId<Manga>))manga);
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== URL → Manga ===========================
|
|
||||||
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
|
|
||||||
{
|
|
||||||
Match m = SeriesUrl.Match(url);
|
|
||||||
if (!m.Success)
|
|
||||||
return null;
|
|
||||||
return GetMangaFromId($"{m.Groups["id"].Value}/{m.Groups["slug"].Value}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== ID → Manga ============================
|
|
||||||
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
|
|
||||||
{
|
|
||||||
string[] parts = mangaIdOnSite.Split('/', 2);
|
|
||||||
if (parts.Length != 2)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
string id = parts[0];
|
|
||||||
string slug = parts[1];
|
|
||||||
|
|
||||||
string url = $"https://www.mangaworld.cx/manga/{id}/{slug}/";
|
|
||||||
RequestResult res = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
|
||||||
if ((int)res.statusCode < 200 || (int)res.statusCode >= 300)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
using StreamReader sr = new (res.result);
|
|
||||||
string html = sr.ReadToEnd();
|
|
||||||
|
|
||||||
HtmlDocument doc = new ();
|
|
||||||
doc.LoadHtml(html);
|
|
||||||
|
|
||||||
string title =
|
|
||||||
doc.DocumentNode.SelectSingleNode("//meta[@property='og:title']")?.GetAttributeValue("content", null)
|
|
||||||
?? doc.DocumentNode.SelectSingleNode("//h1")?.InnerText?.Trim()
|
|
||||||
?? slug.Replace('-', ' ');
|
|
||||||
|
|
||||||
title = CleanTitleSuffix(title);
|
|
||||||
|
|
||||||
string cover =
|
|
||||||
ExtractOgImage(html, new Uri(url))
|
|
||||||
?? doc.DocumentNode.SelectSingleNode("//div[contains(@class,'cover') or contains(@class,'poster')]//img[@src or @data-src]")?.GetAttributeValue("data-src", null)
|
|
||||||
?? doc.DocumentNode.SelectSingleNode("//div[contains(@class,'cover') or contains(@class,'poster')]//img[@src or @data-src]")?.GetAttributeValue("src", null)
|
|
||||||
?? string.Empty;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(cover))
|
|
||||||
cover = MakeAbsoluteUrl(new Uri(url), cover);
|
|
||||||
|
|
||||||
string description =
|
|
||||||
doc.DocumentNode.SelectSingleNode("//meta[@name='description']")?.GetAttributeValue("content", null)
|
|
||||||
?? HtmlEntity.DeEntitize(
|
|
||||||
doc.DocumentNode.SelectSingleNode("//div[contains(@class,'description') or contains(@class,'trama')]")
|
|
||||||
?.InnerText ?? string.Empty
|
|
||||||
).Trim();
|
|
||||||
|
|
||||||
// === STATO (scheda dettaglio) ===
|
|
||||||
MangaReleaseStatus status = MangaReleaseStatus.Unreleased;
|
|
||||||
string? detailRawStatus = ExtractItalianStatus(doc);
|
|
||||||
if (!string.IsNullOrWhiteSpace(detailRawStatus))
|
|
||||||
status = MapItalianStatus(detailRawStatus);
|
|
||||||
|
|
||||||
Manga m = new (
|
|
||||||
HtmlEntity.DeEntitize(title).Trim(),
|
|
||||||
description,
|
|
||||||
cover,
|
|
||||||
status,
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
originalLanguage: "it");
|
|
||||||
MangaConnectorId<Manga> mcId = new (m,
|
|
||||||
this,
|
|
||||||
$"{id}/{slug}",
|
|
||||||
$"https://www.mangaworld.cx/manga/{id}/{slug}/");
|
|
||||||
m.MangaConnectorIds.Add(mcId);
|
|
||||||
return (m, mcId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================== CAPITOLI ============================
|
|
||||||
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> mangaId, string? language = null)
|
|
||||||
{
|
|
||||||
string[] parts = mangaId.IdOnConnectorSite.Split('/', 2);
|
|
||||||
if (parts.Length != 2)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
string id = parts[0];
|
|
||||||
string slug = parts[1];
|
|
||||||
string seriesUrl = $"https://www.mangaworld.cx/manga/{id}/{slug}/";
|
|
||||||
|
|
||||||
string html = FetchHtmlWithFallback(seriesUrl, out Uri baseUri);
|
|
||||||
if (string.IsNullOrEmpty(html))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
HtmlDocument doc = new ();
|
|
||||||
doc.LoadHtml(html);
|
|
||||||
|
|
||||||
List<(Chapter, MangaConnectorId<Chapter>)> chapters = ParseChaptersFromHtml(mangaId.Obj ,doc, baseUri);
|
|
||||||
|
|
||||||
// Ordinamento finale: Volume → Capitolo (numerico)
|
|
||||||
return chapters
|
|
||||||
.OrderBy(c => c.Item1, new Chapter.ChapterComparer())
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== IMMAGINI CAPITOLO =======================
|
|
||||||
private static readonly Regex ImagesArray = new(@"images\s*=\s*\[(?<arr>.*?)\]", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
|
||||||
private static readonly Regex UrlInQuotes = new("\"(https?[^\"\\]]+)\"");
|
|
||||||
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
|
|
||||||
{
|
|
||||||
string url = EnsureListStyle(chapterId.WebsiteUrl ?? $"https://www.mangaworld.cx/manga/{chapterId.IdOnConnectorSite}");
|
|
||||||
|
|
||||||
RequestResult res = downloadClient.MakeRequest(url, RequestType.MangaInfo);
|
|
||||||
if ((int)res.statusCode < 200 || (int)res.statusCode >= 300)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
using StreamReader sr = new (res.result);
|
|
||||||
string html = sr.ReadToEnd();
|
|
||||||
|
|
||||||
Uri baseUri = new (url);
|
|
||||||
|
|
||||||
HtmlDocument doc = new ();
|
|
||||||
doc.LoadHtml(html);
|
|
||||||
|
|
||||||
HtmlNodeCollection imageNodes = doc.DocumentNode.SelectNodes("//img[@data-src or @src or @srcset]") ?? new HtmlNodeCollection(null);
|
|
||||||
|
|
||||||
IEnumerable<string> fromDom = imageNodes
|
|
||||||
.SelectMany(i =>
|
|
||||||
{
|
|
||||||
var list = new List<string>();
|
|
||||||
string ds = i.GetAttributeValue("data-src", "");
|
|
||||||
string s = i.GetAttributeValue("src", "");
|
|
||||||
string ss = i.GetAttributeValue("srcset", "");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ds))
|
|
||||||
list.Add(ds);
|
|
||||||
if (!string.IsNullOrEmpty(s))
|
|
||||||
list.Add(s);
|
|
||||||
if (!string.IsNullOrEmpty(ss))
|
|
||||||
{
|
|
||||||
foreach (string part in ss.Split(','))
|
|
||||||
{
|
|
||||||
string p = part.Trim().Split(' ')[0];
|
|
||||||
if (!string.IsNullOrWhiteSpace(p))
|
|
||||||
list.Add(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
})
|
|
||||||
.Select(x => MakeAbsoluteUrl(baseUri, x))
|
|
||||||
.Where(u =>
|
|
||||||
{
|
|
||||||
string z = u.ToLowerInvariant();
|
|
||||||
return z.StartsWith("http") && (z.Contains(".jpg") || z.Contains(".jpeg") || z.Contains(".png") || z.Contains(".webp"));
|
|
||||||
});
|
|
||||||
|
|
||||||
Match m = ImagesArray.Match(html);
|
|
||||||
IEnumerable<string> fromJs = [];
|
|
||||||
if (m.Success)
|
|
||||||
{
|
|
||||||
MatchCollection urls = UrlInQuotes.Matches(m.Groups["arr"].Value);
|
|
||||||
fromJs = urls.Select(mm => MakeAbsoluteUrl(baseUri, mm.Groups[1].Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<string> final = new ();
|
|
||||||
HashSet<string> seen = new (StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (string u in fromDom.Concat(fromJs))
|
|
||||||
if (seen.Add(u))
|
|
||||||
final.Add(u);
|
|
||||||
|
|
||||||
return final.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ PARSER CAPITOLI ===================
|
|
||||||
private static readonly Regex RexVolume = new(@"[Vv]olume\s+([0-9]+)", RegexOptions.Compiled);
|
|
||||||
private static readonly Regex RexChapter = new(@"(?:\b[Cc]apitolo|\b[Cc]h(?:apter)?)\s*([0-9]+(?:\.[0-9]+)?)", RegexOptions.Compiled);
|
|
||||||
private static readonly Regex RexChapterId = new(@"manga\/([0-9]+\/[a-z0-9\-]+\/read\/[a-z0-9]+)\/", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private List<(Chapter, MangaConnectorId<Chapter>)> ParseChaptersFromHtml(Manga manga, HtmlDocument document, Uri baseUri)
|
|
||||||
{
|
|
||||||
List<(Chapter, MangaConnectorId<Chapter>)> ret = new ();
|
|
||||||
|
|
||||||
// wrapper principale
|
|
||||||
HtmlNode? chaptersWrapper = document.DocumentNode.SelectSingleNode("//div[contains(@class,'chapters-wrapper')]");
|
|
||||||
// layout A: volumi raggruppati
|
|
||||||
HtmlNodeCollection? volumeElements = document.DocumentNode.SelectNodes("//div[contains(@class,'volume-element')]");
|
|
||||||
|
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
|
||||||
if (volumeElements is not null && volumeElements.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (HtmlNode volNode in volumeElements)
|
|
||||||
{
|
|
||||||
// titolo volume, es. "<p>Volume 24</p>"
|
|
||||||
string volText = volNode.SelectSingleNode(".//div[contains(@class,'volume')]/p")?.InnerText ?? string.Empty;
|
|
||||||
|
|
||||||
int? volumeNumber = null;
|
|
||||||
Match vm = RexVolume.Match(volText);
|
|
||||||
if (vm.Success && int.TryParse(vm.Groups[1].Value, out int volParsed))
|
|
||||||
volumeNumber = volParsed;
|
|
||||||
|
|
||||||
// capitoli dentro il blocco volume
|
|
||||||
HtmlNodeCollection chapterNodes = volNode
|
|
||||||
.SelectSingleNode(".//div[contains(@class,'volume-chapters')]")
|
|
||||||
?.SelectNodes(".//div") ?? new HtmlNodeCollection(null);
|
|
||||||
|
|
||||||
foreach (HtmlNode chNode in chapterNodes)
|
|
||||||
{
|
|
||||||
HtmlNode? anchor = chNode.SelectSingleNode(".//a[@href]");
|
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
|
||||||
if (anchor is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string spanText = anchor.SelectSingleNode(".//span")?.InnerText ?? anchor.InnerText ?? string.Empty;
|
|
||||||
|
|
||||||
Match cm = RexChapter.Match(spanText);
|
|
||||||
if (!cm.Success)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string chapterNumber = NormalizeNumber(cm.Groups[1].Value);
|
|
||||||
string href = anchor.GetAttributeValue("href", "");
|
|
||||||
if (string.IsNullOrWhiteSpace(href))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string rel = MakeAbsoluteUrl(baseUri, href);
|
|
||||||
string ensured = EnsureListStyle(EnsureReaderUrlHasPage(rel));
|
|
||||||
|
|
||||||
Match idMatch = RexChapterId.Match(ensured);
|
|
||||||
if(!idMatch.Success)
|
|
||||||
continue;
|
|
||||||
string id = idMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
Chapter chapter = new (manga, chapterNumber, volumeNumber);
|
|
||||||
MangaConnectorId<Chapter> chId = new(chapter, this, id, ensured);
|
|
||||||
chapter.MangaConnectorIds.Add(chId);
|
|
||||||
|
|
||||||
// title:null per evitare duplicazioni nel filename
|
|
||||||
ret.Add((chapter, chId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// layout B: lista piatta (niente blocchi volume) → v1: Volume 0
|
|
||||||
HtmlNodeCollection chapterNodes = chaptersWrapper?.SelectNodes(".//div[contains(@class,'chapter')]")
|
|
||||||
?? document.DocumentNode.SelectNodes("//div[contains(@class,'chapter')]")
|
|
||||||
?? new HtmlNodeCollection(null);
|
|
||||||
|
|
||||||
foreach (HtmlNode chNode in chapterNodes)
|
|
||||||
{
|
|
||||||
HtmlNode? anchor = chNode.SelectSingleNode(".//a[@href]") ?? chNode.SelectSingleNode(".//a");
|
|
||||||
if (anchor is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string spanText = anchor.SelectSingleNode(".//span")?.InnerText ?? anchor.InnerText ?? string.Empty;
|
|
||||||
|
|
||||||
Match cm = RexChapter.Match(spanText);
|
|
||||||
if (!cm.Success)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string chapterNumber = NormalizeNumber(cm.Groups[1].Value);
|
|
||||||
string href = anchor.GetAttributeValue("href", "");
|
|
||||||
if (string.IsNullOrWhiteSpace(href))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
string rel = MakeAbsoluteUrl(baseUri, href);
|
|
||||||
string ensured = EnsureListStyle(EnsureReaderUrlHasPage(rel));
|
|
||||||
|
|
||||||
Match idMatch = RexChapterId.Match(ensured);
|
|
||||||
if(!idMatch.Success)
|
|
||||||
continue;
|
|
||||||
string id = idMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
// v1 behaviour: senza volumi → Volume 0
|
|
||||||
Chapter chapter = new (manga, chapterNumber, null);
|
|
||||||
MangaConnectorId<Chapter> chId = new(chapter, this, id, ensured);
|
|
||||||
|
|
||||||
ret.Add((chapter, chId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ HELPERS ===========================
|
|
||||||
private static readonly Regex SeriesUrl = new(@"https?://[^/]+/manga/(?<id>\d+)/(?<slug>[^/]+)/?", RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
private string FetchHtmlWithFallback(string seriesUrl, out Uri baseUri)
|
|
||||||
{
|
|
||||||
baseUri = new (seriesUrl);
|
|
||||||
|
|
||||||
// 1) tenta client "Default"
|
|
||||||
RequestResult res = downloadClient.MakeRequest(seriesUrl, RequestType.Default);
|
|
||||||
if ((int)res.statusCode >= 200 && (int)res.statusCode < 300)
|
|
||||||
{
|
|
||||||
using StreamReader sr = new (res.result);
|
|
||||||
string html = sr.ReadToEnd();
|
|
||||||
if (!LooksLikeChallenge(html))
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) fallback: client “MangaInfo” (proxy/Flare se configurato)
|
|
||||||
RequestResult res2 = downloadClient.MakeRequest(seriesUrl, RequestType.MangaInfo);
|
|
||||||
if ((int)res2.statusCode >= 200 && (int)res2.statusCode < 300)
|
|
||||||
{
|
|
||||||
using StreamReader sr2 = new StreamReader(res2.result);
|
|
||||||
return sr2.ReadToEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool LooksLikeChallenge(string html)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(html)) return true;
|
|
||||||
string h = html.ToLowerInvariant();
|
|
||||||
return h.Contains("cf-challenge") ||
|
|
||||||
h.Contains("cf-browser-verification") ||
|
|
||||||
h.Contains("just a moment") ||
|
|
||||||
h.Contains("verify you are human") ||
|
|
||||||
h.Contains("captcha");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EnsureReaderUrlHasPage(string url)
|
|
||||||
{
|
|
||||||
Match m = Regex.Match(url, @"(/read/[0-9a-fA-F]{16,64})(/(\d+))?", RegexOptions.IgnoreCase);
|
|
||||||
if (m.Success && string.IsNullOrEmpty(m.Groups[2].Value))
|
|
||||||
{
|
|
||||||
int qIdx = url.IndexOf('?', StringComparison.Ordinal);
|
|
||||||
if (qIdx >= 0)
|
|
||||||
url = url.Insert(qIdx, "/1");
|
|
||||||
else
|
|
||||||
url = url.TrimEnd('/') + "/1";
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EnsureListStyle(string url)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(url))
|
|
||||||
return url;
|
|
||||||
if (url.Contains("style=list", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return url;
|
|
||||||
return url.Contains('?') ? (url + "&style=list") : (url + "?style=list");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeNumber(string s)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(s))
|
|
||||||
return "0";
|
|
||||||
s = s.Trim();
|
|
||||||
Match m = Regex.Match(s, @"^\s*0*(\d+)(?:\.(\d+))?\s*$");
|
|
||||||
if (!m.Success)
|
|
||||||
return s;
|
|
||||||
string intPart = m.Groups[1].Value.TrimStart('0');
|
|
||||||
if (intPart.Length == 0)
|
|
||||||
intPart = "0";
|
|
||||||
string frac = m.Groups[2].Success
|
|
||||||
? "." + m.Groups[2].Value
|
|
||||||
: "";
|
|
||||||
return intPart + frac;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string MakeAbsoluteUrl(Uri baseUri, string s)
|
|
||||||
{
|
|
||||||
s = s.Trim();
|
|
||||||
if (s.StartsWith("//"))
|
|
||||||
return "https:" + s;
|
|
||||||
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
s.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return s;
|
|
||||||
if (s.StartsWith("/"))
|
|
||||||
return new Uri(baseUri, s).ToString();
|
|
||||||
return new Uri(baseUri, s).ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ExtractOgImage(string html, Uri baseUri)
|
|
||||||
{
|
|
||||||
HtmlDocument doc = new ();
|
|
||||||
doc.LoadHtml(html);
|
|
||||||
string? og = doc.DocumentNode.SelectSingleNode("//meta[@property='og:image']")?.GetAttributeValue("content", null);
|
|
||||||
return string.IsNullOrWhiteSpace(og) ? null : MakeAbsoluteUrl(baseUri, og!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== TITLE CLEANUP (suffisso MW) ==============
|
|
||||||
private static readonly Regex MwSuffix = new(@"\s*(Scan\s\w+\s-\sMangaWorld)$", RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
private static string CleanTitleSuffix(string? t)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(t))
|
|
||||||
return t ?? string.Empty;
|
|
||||||
return MwSuffix.Replace(t, "").Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== STATO (estrazione + mapping) =============
|
|
||||||
private static string? ExtractItalianStatus(HtmlDocument doc)
|
|
||||||
{
|
|
||||||
// 1) Percorso più comune: "Stato: <valore>"
|
|
||||||
HtmlNode? node = doc.DocumentNode.SelectSingleNode("//span[normalize-space(text())='Stato:']/following-sibling::*[1]")
|
|
||||||
?? doc.DocumentNode.SelectSingleNode("//span[contains(translate(., 'STATO', 'stato'), 'stato')]/following-sibling::*[1]");
|
|
||||||
string? val = node?.InnerText?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(val)) return HtmlEntity.DeEntitize(val);
|
|
||||||
|
|
||||||
// 2) Blocchi info vari (tollerante a cambi DOM)
|
|
||||||
HtmlNodeCollection? blocks = doc.DocumentNode.SelectNodes("//*[contains(@class,'info') or contains(@class,'details') or contains(@class,'meta') or contains(@class,'attributes') or contains(@class,'list-group')]");
|
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
|
||||||
if (blocks is not null)
|
|
||||||
{
|
|
||||||
foreach (HtmlNode block in blocks)
|
|
||||||
{
|
|
||||||
HtmlNodeCollection labels = block.SelectNodes(".//dt|.//li|.//div|.//span|.//strong") ?? new HtmlNodeCollection(null);
|
|
||||||
foreach (HtmlNode label in labels)
|
|
||||||
{
|
|
||||||
string? t = label.InnerText?.Trim()?.ToLowerInvariant();
|
|
||||||
if (string.IsNullOrEmpty(t))
|
|
||||||
continue;
|
|
||||||
if (t != "stato" && t != "stato:" && !t.Contains("stato"))
|
|
||||||
continue;
|
|
||||||
string? vv = label.SelectSingleNode("./following-sibling::*[1]")?.InnerText?.Trim()
|
|
||||||
?? label.ParentNode?.SelectSingleNode(".//a|.//span|.//strong")?.InnerText?.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(vv))
|
|
||||||
return HtmlEntity.DeEntitize(vv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Fallback testuale grezzo
|
|
||||||
string body = doc.DocumentNode.InnerText;
|
|
||||||
Match m = Regex.Match(body, @"Stato\s*:\s*([A-Za-zÀ-ÿ\s\-]+)", RegexOptions.IgnoreCase);
|
|
||||||
return m.Success
|
|
||||||
? m.Groups[1].Value.Trim()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MangaReleaseStatus MapItalianStatus(string s) => s.Trim().ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"in corso" or "ongoing" or "attivo" => MangaReleaseStatus.Continuing,
|
|
||||||
"completo" or "concluso" or "finito" or "terminato" or "completed" => MangaReleaseStatus.Completed,
|
|
||||||
"in pausa" or "pausa" or "hiatus" or "sospeso" => MangaReleaseStatus.OnHiatus,
|
|
||||||
"droppato" or "cancellato" or "abbandonato" or "cancelled" or "interrotto" => MangaReleaseStatus.Cancelled,
|
|
||||||
_ => MangaReleaseStatus.Unreleased
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@@ -19,7 +19,7 @@ public static class Tranga
|
|||||||
|
|
||||||
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
|
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
|
||||||
internal static readonly MetadataFetcher[] MetadataFetchers = [new MyAnimeList()];
|
internal static readonly MetadataFetcher[] MetadataFetchers = [new MyAnimeList()];
|
||||||
internal static readonly MangaConnector[] MangaConnectors = [new Global(), new MangaDex(), new ComickIo(), new Mangaworld()];
|
internal static readonly MangaConnector[] MangaConnectors = [new Global(), new MangaDex(), new ComickIo()];
|
||||||
internal static TrangaSettings Settings = TrangaSettings.Load();
|
internal static TrangaSettings Settings = TrangaSettings.Load();
|
||||||
|
|
||||||
internal static readonly UpdateMetadataWorker UpdateMetadataWorker = new ();
|
internal static readonly UpdateMetadataWorker UpdateMetadataWorker = new ();
|
||||||
|
@@ -73,24 +73,23 @@ public abstract class BaseWorker : Identifiable
|
|||||||
{
|
{
|
||||||
// Start the worker
|
// Start the worker
|
||||||
Log.Debug($"Checking {this}");
|
Log.Debug($"Checking {this}");
|
||||||
this._cancellationTokenSource = new(TimeSpan.FromMinutes(10));
|
_cancellationTokenSource = new(TimeSpan.FromMinutes(10));
|
||||||
this.State = WorkerExecutionState.Waiting;
|
State = WorkerExecutionState.Waiting;
|
||||||
|
|
||||||
// Wait for dependencies, start them if necessary
|
// Wait for dependencies, start them if necessary
|
||||||
BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray();
|
BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray();
|
||||||
if(missingDependenciesThatNeedStarting.Any())
|
if(missingDependenciesThatNeedStarting.Any())
|
||||||
return new Task<BaseWorker[]>(() => missingDependenciesThatNeedStarting);
|
return new (() => missingDependenciesThatNeedStarting);
|
||||||
|
|
||||||
if (MissingDependencies.Any())
|
if (MissingDependencies.Any())
|
||||||
return new Task<BaseWorker[]>(WaitForDependencies);
|
return new (WaitForDependencies);
|
||||||
|
|
||||||
// Run the actual work
|
// Run the actual work
|
||||||
Log.Info($"Running {this}");
|
Log.Info($"Running {this}");
|
||||||
DateTime startTime = DateTime.UtcNow;
|
DateTime startTime = DateTime.UtcNow;
|
||||||
Task<BaseWorker[]> task = new Task<BaseWorker[]>(() => DoWorkInternal().Result);
|
State = WorkerExecutionState.Running;
|
||||||
|
Task<BaseWorker[]> task = DoWorkInternal();
|
||||||
task.GetAwaiter().OnCompleted(Finish(startTime, callback));
|
task.GetAwaiter().OnCompleted(Finish(startTime, callback));
|
||||||
this.State = WorkerExecutionState.Running;
|
|
||||||
task.Start();
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
65
README.md
65
README.md
@@ -35,8 +35,7 @@
|
|||||||
Tranga can download Chapters and Metadata from "Scanlation" sites such as
|
Tranga can download Chapters and Metadata from "Scanlation" sites such as
|
||||||
|
|
||||||
- [MangaDex.org](https://mangadex.org/) (Multilingual)
|
- [MangaDex.org](https://mangadex.org/) (Multilingual)
|
||||||
- [Comick.io](https://comick.io/) (Multilingual)
|
- [Comick.io](https://comick.io/)
|
||||||
- [MangaWorld](https://www.mangaworld.cx) (it)
|
|
||||||
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+)
|
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+)
|
||||||
|
|
||||||
and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
|
and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
|
||||||
@@ -89,7 +88,7 @@ Endpoints are documented in Swagger. Just spin up an instance, and go to `http:/
|
|||||||
## Built With
|
## Built With
|
||||||
|
|
||||||
- ASP.NET
|
- ASP.NET
|
||||||
- Entity Framework Core
|
- Entity Framework Core (EF Core)
|
||||||
- [PostgreSQL](https://www.postgresql.org/about/licence/)
|
- [PostgreSQL](https://www.postgresql.org/about/licence/)
|
||||||
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
|
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
|
||||||
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
|
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
|
||||||
@@ -141,7 +140,7 @@ Downloads (default) are stored in - but this can be configured in `settings.json
|
|||||||
- Linux `/Manga`
|
- Linux `/Manga`
|
||||||
- Windows `%currentDirectory%/Downloads`
|
- Windows `%currentDirectory%/Downloads`
|
||||||
|
|
||||||
#### Prerequisits
|
### Prerequisits
|
||||||
|
|
||||||
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
|
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
|
||||||
|
|
||||||
@@ -150,15 +149,54 @@ Downloads (default) are stored in - but this can be configured in `settings.json
|
|||||||
|
|
||||||
If you want to contribute, please feel free to fork and create a Pull-Request!
|
If you want to contribute, please feel free to fork and create a Pull-Request!
|
||||||
|
|
||||||
General rules:
|
### General rules
|
||||||
- Strongly-type your variables. This improves readability.
|
|
||||||
```csharp
|
|
||||||
var xyz = Object.GetSomething(); //Do not do this. What type is xyz (without looking at Method returns etc.)?
|
|
||||||
Manga[] zyx = Object.GetAnotherThing(); //I can now easily see that zyx is an Array.
|
|
||||||
```
|
|
||||||
Tranga is using a code-first Entity-Framework Core approach. If you modify the db-table structure you need to create a migration.
|
|
||||||
|
|
||||||
**A broad overview of where is what:**<br />
|
- Strong-type your variables. This improves readability.
|
||||||
|
- **DO**
|
||||||
|
```csharp
|
||||||
|
Manga[] zyx = Object.GetAnotherThing(); //I can see that zyx is an Array, without digging through more code
|
||||||
|
```
|
||||||
|
- **DO _NOT_**
|
||||||
|
```csharp
|
||||||
|
var xyz = Object.GetSomething(); //What is xyz? An Array? A string? An object?
|
||||||
|
```
|
||||||
|
|
||||||
|
- Indent your `if` and `for` blocks
|
||||||
|
- **DO**
|
||||||
|
```csharp
|
||||||
|
if(true)
|
||||||
|
return false;
|
||||||
|
```
|
||||||
|
- **DO _NOT_**
|
||||||
|
```csharp
|
||||||
|
if(true) return false;
|
||||||
|
```
|
||||||
|
<details>
|
||||||
|
<summary>Because try reading this</summary>
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return s;
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
- When using shorthand, _this_ improves readability for longer lines (at some point just use if-else...):
|
||||||
|
```csharp
|
||||||
|
bool retVal = xyz is true
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
|
```
|
||||||
|
```csharp
|
||||||
|
bool retVal = xyz?
|
||||||
|
?? abc?
|
||||||
|
?? true;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database and EF Core
|
||||||
|
|
||||||
|
Tranga is using a **code-first** EF-Core approach. If you modify the database(context) structure you need to create a migration.
|
||||||
|
|
||||||
|
### A broad overview of where is what:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -168,7 +206,8 @@ Tranga is using a code-first Entity-Framework Core approach. If you modify the d
|
|||||||
- `MangaDownloadClients/**` Networking-Clients for Scraping
|
- `MangaDownloadClients/**` Networking-Clients for Scraping
|
||||||
- `Controllers/**` ASP.NET Controllers (Endpoints)
|
- `Controllers/**` ASP.NET Controllers (Endpoints)
|
||||||
|
|
||||||
If you want to add a new Website-Connector: <br />
|
##### If you want to add a new Website-Connector:
|
||||||
|
|
||||||
1. Copy one of the existing connectors, or start from scratch and inherit from `API.Schema.MangaConnectors.MangaConnector`.
|
1. Copy one of the existing connectors, or start from scratch and inherit from `API.Schema.MangaConnectors.MangaConnector`.
|
||||||
2. Add the new Connector as Object-Instance in `Tranga.cs` to the MangaConnector-Array `connectors`.
|
2. Add the new Connector as Object-Instance in `Tranga.cs` to the MangaConnector-Array `connectors`.
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user