diff --git a/Core/Configs/Options.cs b/Core/Configs/Options.cs index 6b7d8989..a0efbe48 100644 --- a/Core/Configs/Options.cs +++ b/Core/Configs/Options.cs @@ -23,6 +23,9 @@ public Options(IEnumerable url) { [Option('p', "password", Required = false, HelpText = "Пароль от системы")] public string Password { get; set; } + + [Option('k', "token", Required = false, HelpText = "Готовый токен авторизации")] + public string Token { get; set; } [Option("proxy", Required = false, HelpText = "Прокси в формате :")] public string Proxy { get; set; } @@ -74,6 +77,12 @@ public Options(IEnumerable url) { [Option("book-name-pattern", Required = false, HelpText = "Шаблон имени файла.", Default = "{Author.Name} - {Book.Title}")] public string BookNamePattern { get; set; } = "{Author.Name} - {Book.Title}"; + + [Option("split-volumes", Required = false, HelpText = "Разбивать по томам (если поддерживается)")] + public bool SplitVolumes { get; set; } + + [Option("split-chapters", Required = false, HelpText = "Разбивать по главам (если поддерживается)")] + public bool SplitChapters { get; set; } public bool HasAdditionalType(AdditionalTypeEnum type) => Additional && (AdditionalTypes == default || !AdditionalTypes.Any() || AdditionalTypes.Contains(type)); diff --git a/Core/Logic/Builders/BuilderBase.cs b/Core/Logic/Builders/BuilderBase.cs index 77963238..4a84d492 100644 --- a/Core/Logic/Builders/BuilderBase.cs +++ b/Core/Logic/Builders/BuilderBase.cs @@ -20,6 +20,7 @@ public abstract class BuilderBase(Options options, ILogger logger) { /// Книга /// Имя файла protected abstract Task BuildInternal(Book book, string fileName); + protected virtual async Task SplitBuild(Book book, string fileName) { await BuildInternal(book, fileName); } /// /// Получение имени файла @@ -41,6 +42,11 @@ public abstract class BuilderBase(Options options, ILogger logger) { /// Книга public async Task Build(Book book) { var fileName = GetFileName(book); + + if (book.SupportSplitting && (Options.SplitChapters || Options.SplitVolumes)) + { + fileName = GetTitle(book); + } if (!string.IsNullOrWhiteSpace(Options.SavePath)) { if (!Directory.Exists(Options.SavePath)) { @@ -51,7 +57,11 @@ public async Task Build(Book book) { } Logger.LogInformation($"Начинаю сохранение книги {fileName.CoverQuotes()}"); - await BuildInternal(book, fileName); + if (book.SupportSplitting && (Options.SplitChapters || Options.SplitVolumes)) { + await SplitBuild(book, fileName); + } else { + await BuildInternal(book, fileName); + } if (Options.Cover) { await SaveCover(Options.SavePath, book.Cover, GetTitle(book)); diff --git a/Core/Logic/Builders/CbzBuilder.cs b/Core/Logic/Builders/CbzBuilder.cs index 190a28a2..b39c7b60 100644 --- a/Core/Logic/Builders/CbzBuilder.cs +++ b/Core/Logic/Builders/CbzBuilder.cs @@ -1,34 +1,140 @@ using System.IO; using System.IO.Compression; +using System.Linq; using System.Threading.Tasks; using Core.Configs; using Core.Types.Book; using Microsoft.Extensions.Logging; -namespace Core.Logic.Builders; +namespace Core.Logic.Builders; -public class CbzBuilder(Options options, ILogger logger) : BuilderBase(options, logger) { +public class CbzBuilder(Options options, ILogger logger) : BuilderBase(options, logger) +{ protected override string Extension => "cbz"; - protected override async Task BuildInternal(Book book, string fileName) { - if (File.Exists(fileName)) { + protected override async Task BuildInternal(Book book, string fileName) + { + if (File.Exists(fileName)) + { File.Delete(fileName); } - + using var archive = ZipFile.Open(fileName, ZipArchiveMode.Create); - + var c = 0; - foreach (var chapter in book.Chapters) { - if (chapter.Images == default) { + foreach (var chapter in book.Chapters) + { + if (chapter.Images == default) + { continue; } - foreach (var image in chapter.Images) { - var entry = archive.CreateEntry($"{++c}{image.Extension}", CompressionLevel.Optimal); + foreach (var image in chapter.Images) + { + var image_name = $"{chapter.VolumeNumber}-{chapter.ChapterNumber}-{++c}{image.Extension}"; + var entry = archive.CreateEntry(image_name, CompressionLevel.Optimal); await using var entryStream = entry.Open(); await using var fileStream = image.GetStream(); await fileStream.CopyToAsync(entryStream); } } } + + protected override async Task SplitBuild(Book book, string fileName) + { + if (options.SplitVolumes && options.SplitChapters) + { + foreach (var volumeNumber in book.Volumes) + { + var volume_name = $"{fileName}.v{volumeNumber}.{Extension}"; + using var volume_archive = ZipFile.Open(volume_name, ZipArchiveMode.Create); + + foreach (var chapter in book.Chapters.Where(c => c.VolumeNumber == volumeNumber)) + { + if (chapter.Images == default) + { + continue; + } + + var chapter_name = $"{fileName}.v{chapter.VolumeNumber}.c{chapter.ChapterNumber}.{Extension}"; + + using var chapter_archive = ZipFile.Open(chapter_name, ZipArchiveMode.Create); + + var c = 0; + foreach (var image in chapter.Images) + { + var image_name = $"{chapter.VolumeNumber}-{chapter.ChapterNumber}-{++c}{image.Extension}"; + var volume_entry = volume_archive.CreateEntry(image_name, CompressionLevel.Optimal); + var chapter_entry = chapter_archive.CreateEntry(image_name, CompressionLevel.Optimal); + await using var volume_entryStream = volume_entry.Open(); + await using var chapter_entryStream = chapter_entry.Open(); + await using var volume_fileStream = image.GetStream(); + await using var chapter_fileStream = image.GetStream(); + await volume_fileStream.CopyToAsync(volume_entryStream); + await chapter_fileStream.CopyToAsync(chapter_entryStream); + } + Logger.LogInformation($"Книга {chapter_name} успешно сохранена"); + chapter_archive.Dispose(); + } + Logger.LogInformation($"Книга {volume_name} успешно сохранена"); + volume_archive.Dispose(); + } + } + else if (options.SplitVolumes) + { + foreach (var volumeNumber in book.Volumes) + { + var volume_name = $"{fileName}.v{volumeNumber}.{Extension}"; + + using var volume_archive = ZipFile.Open(volume_name, ZipArchiveMode.Create); + + var c = 0; + foreach (var chapter in book.Chapters.Where(c => c.VolumeNumber == volumeNumber)) + { + if (chapter.Images == default) + { + continue; + } + foreach (var image in chapter.Images) + { + var image_name = $"{chapter.VolumeNumber}-{chapter.ChapterNumber}-{++c}{image.Extension}"; + var volume_entry = volume_archive.CreateEntry(image_name, CompressionLevel.Optimal); + await using var volume_entryStream = volume_entry.Open(); + await using var fileStream = image.GetStream(); + await fileStream.CopyToAsync(volume_entryStream); + } + } + Logger.LogInformation($"Книга {volume_name} успешно сохранена"); + volume_archive.Dispose(); + } + } + else if (options.SplitChapters) + { + foreach (var chapter in book.Chapters) + { + var chapter_name = $"{fileName}.v{chapter.VolumeNumber}.c{chapter.ChapterNumber}.{Extension}"; + + using var chapter_archive = ZipFile.Open(chapter_name, ZipArchiveMode.Create); + if (chapter.Images == default) + { + continue; + } + var c = 0; + foreach (var image in chapter.Images) + { + var image_name = $"{chapter.VolumeNumber}-{chapter.ChapterNumber}-{++c}{image.Extension}"; + var chapter_entry = chapter_archive.CreateEntry(image_name, CompressionLevel.Optimal); + await using var chapter_entryStream = chapter_entry.Open(); + await using var fileStream = image.GetStream(); + await fileStream.CopyToAsync(chapter_entryStream); + } + Logger.LogInformation($"Книга {chapter_name} успешно сохранена"); + chapter_archive.Dispose(); + } + } + else + { + await BuildInternal(book, fileName); + } + } } \ No newline at end of file diff --git a/Core/Logic/Builders/Fb2Builder.cs b/Core/Logic/Builders/Fb2Builder.cs index 1fc5a606..446567bf 100644 --- a/Core/Logic/Builders/Fb2Builder.cs +++ b/Core/Logic/Builders/Fb2Builder.cs @@ -14,11 +14,12 @@ using HtmlAgilityPack; using Microsoft.Extensions.Logging; -namespace Core.Logic.Builders; +namespace Core.Logic.Builders; -public class Fb2Builder : BuilderBase { +public class Fb2Builder : BuilderBase +{ protected override string Extension => "fb2"; - + private readonly XNamespace _ns = "http://www.gribuser.ru/xml/fictionbook/2.0"; private readonly XNamespace _xlink = "http://www.w3.org/1999/xlink"; @@ -26,6 +27,7 @@ public class Fb2Builder : BuilderBase { private readonly XElement _titleInfo; private readonly XElement _body; private readonly XElement _documentInfo; + private readonly XElement _customInfo; private readonly List _images = new(); private readonly Dictionary _map = new() { @@ -48,22 +50,27 @@ public class Fb2Builder : BuilderBase { {"u", "u"}, }; - public Fb2Builder(Options options, ILogger logger) : base(options, logger) { + public Fb2Builder(Options options, ILogger logger) : base(options, logger) + { _description = CreateXElement("description"); _titleInfo = CreateXElement("title-info"); _documentInfo = CreateXElement("document-info"); + _customInfo = CreateXElement("custom-info"); _body = CreateXElement("body"); } - private XElement CreateXElement(string name) { + private XElement CreateXElement(string name) + { return new XElement(_ns + name); } - private XElement CreateAuthor(Author author) { + private XElement CreateAuthor(Author author) + { var authorElem = CreateXElement("author"); var parts = author.Name.Split(" "); - if (parts.Length == 2) { + if (parts.Length == 2) + { var firstName = CreateXElement("first-name"); firstName.Value = parts[0]; authorElem.Add(firstName); @@ -71,13 +78,16 @@ private XElement CreateAuthor(Author author) { var lastName = CreateXElement("last-name"); lastName.Value = parts[1]; authorElem.Add(lastName); - } else { + } + else + { var firstName = CreateXElement("first-name"); firstName.Value = author.Name; authorElem.Add(firstName); } - if (author.Url != default) { + if (author.Url != default) + { var homePageElem = CreateXElement("home-page"); homePageElem.Value = author.Url.ToString(); authorElem.Add(homePageElem); @@ -86,17 +96,19 @@ private XElement CreateAuthor(Author author) { return authorElem; } - private XElement CreateTitle(string text) { + private XElement CreateTitle(string text) + { var p = CreateXElement("p"); p.Value = text.HtmlDecode().ReplaceNewLine(); - + var title = CreateXElement("title"); - + title.Add(p); return title; } - private static async Task WriteBinary(XmlWriter writer, TempFile tempFile) { + private static async Task WriteBinary(XmlWriter writer, TempFile tempFile) + { const int bufferSize = 1000; var buffer = new byte[bufferSize]; int readBytes; @@ -107,32 +119,36 @@ private static async Task WriteBinary(XmlWriter writer, TempFile tempFile) { await writer.WriteAttributeStringAsync(null, "content-type", null, "image/" + tempFile.Extension.TrimStart('.')); using var br = new BinaryReader(inputFile); - do { + do + { readBytes = br.Read(buffer, 0, bufferSize); await writer.WriteBase64Async(buffer, 0, readBytes); } while (bufferSize <= readBytes); await writer.WriteEndElementAsync(); } - + /// /// Добавление автора книги /// /// Автор /// - private void AddAuthor(Author author) { + private void AddAuthor(Author author) + { var authorElem = CreateAuthor(author); _titleInfo.Add(authorElem); _documentInfo.Add(authorElem); } - + /// /// Добавление со-авторов книги /// /// Со-авторы /// - private void AddCoAuthors(IEnumerable coAuthors) { - foreach (var coAuthor in coAuthors) { + private void AddCoAuthors(IEnumerable coAuthors) + { + foreach (var coAuthor in coAuthors) + { var coAuthorElem = CreateAuthor(coAuthor); _titleInfo.Add(coAuthorElem); _documentInfo.Add(coAuthorElem); @@ -144,7 +160,8 @@ private void AddCoAuthors(IEnumerable coAuthors) { /// /// Название книги /// - private void WithTitle(string title) { + private void WithTitle(string title) + { var bookTitle = CreateXElement("book-title"); bookTitle.Value = title.ReplaceNewLine(); @@ -157,43 +174,52 @@ private void WithTitle(string title) { /// /// Обложка /// - private void WithCover(TempFile cover) { - if (cover != default) { + private void WithCover(TempFile cover) + { + if (cover != default) + { var coverPage = CreateXElement("coverpage"); - + var imageElem = CreateXElement("image"); imageElem.SetAttributeValue(_xlink + "href", "#" + cover.FullName); - + coverPage.Add(imageElem); _titleInfo.Add(coverPage); _images.Add(cover); } } - private void WithBookUrl(Uri url) { - if (url != default) { + private void WithBookUrl(Uri url) + { + if (url != default) + { var srcUrlElem = CreateXElement("src-url"); srcUrlElem.Value = url.ToString().CleanInvalidXmlChars(); _documentInfo.Add(srcUrlElem); } } - private void WithAnnotation(string annotation) { - if (!string.IsNullOrWhiteSpace(annotation)) { + private void WithAnnotation(string annotation) + { + if (!string.IsNullOrWhiteSpace(annotation)) + { _titleInfo.Add(CreateAnnotation(annotation)); } } - private XElement CreateAnnotation(string annotation) { + private XElement CreateAnnotation(string annotation) + { var a = CreateXElement("annotation"); var doc = annotation.AsHtmlDoc(); - foreach (var node in doc.DocumentNode.ChildNodes) { - if (!string.IsNullOrWhiteSpace(node.InnerText)) { + foreach (var node in doc.DocumentNode.ChildNodes) + { + if (!string.IsNullOrWhiteSpace(node.InnerText)) + { ProcessSection(a, node, "p"); } } - + return a; } @@ -202,97 +228,130 @@ private XElement CreateAnnotation(string annotation) { /// /// Список частей /// - private void WithChapters(IEnumerable chapters) { - foreach (var chapter in chapters.Where(c => c.IsValid)) { + private void WithChapters(IEnumerable chapters) + { + foreach (var chapter in chapters.Where(c => c.IsValid)) + { var section = CreateXElement("section"); section.Add(CreateTitle(chapter.Title)); - + var doc = chapter.Content.AsHtmlDoc(); - foreach (var node in doc.DocumentNode.ChildNodes) { + foreach (var node in doc.DocumentNode.ChildNodes) + { ProcessSection(section, node); } - + _body.Add(section); - foreach (var image in chapter.Images) { + foreach (var image in chapter.Images) + { _images.Add(image); } } } - private void WithSeria(Seria seria) { - if (seria != default) { + private void WithSeria(Seria seria) + { + if (seria != default) + { var sequenceElem = CreateXElement("sequence"); sequenceElem.SetAttributeValue("name", seria.Name.CleanInvalidXmlChars()); sequenceElem.SetAttributeValue("number", seria.Number.CleanInvalidXmlChars()); _titleInfo.Add(sequenceElem); + // seria url + var SeriaUrl = seria.Url.ToString(); + if( !string.IsNullOrWhiteSpace(SeriaUrl) && SeriaUrl.Length > 14 ) + { + _customInfo.SetAttributeValue("info-type", "sequence-url"); + _customInfo.Add(SeriaUrl.CleanInvalidXmlChars()); + } } } - private void WithLang(string lang) { - if (!string.IsNullOrWhiteSpace(lang)) { + private void WithLang(string lang) + { + if (!string.IsNullOrWhiteSpace(lang)) + { var langElem = CreateXElement("lang"); langElem.Value = lang; _titleInfo.Add(langElem); } } - private static bool IsTextNode(HtmlNode node) { + private static bool IsTextNode(HtmlNode node) + { return node.Name is "#text" or "span"; } - private void ProcessSection(XElement parent, HtmlNode node, string textNode = "") { - if (node.Name == "br") { + private void ProcessSection(XElement parent, HtmlNode node, string textNode = "") + { + if (node.Name == "br") + { parent.Add(CreateXElement("br")); return; } - - if (node.Name == "a") { + + if (node.Name == "a") + { var href = node.Attributes["href"]?.Value ?? string.Empty; - if (string.IsNullOrWhiteSpace(href)) { - if (string.IsNullOrWhiteSpace(textNode)) { + if (string.IsNullOrWhiteSpace(href)) + { + if (string.IsNullOrWhiteSpace(textNode)) + { parent.Add(node.InnerText); - } else { + } + else + { var tag = CreateXElement(textNode); tag.Value = node.InnerText; parent.Add(tag); } - } else { + } + else + { var a = CreateXElement("a"); a.SetAttributeValue(_xlink + "href", href); a.Value = node.InnerText; - - if (string.IsNullOrWhiteSpace(textNode)) { + + if (string.IsNullOrWhiteSpace(textNode)) + { parent.Add(a); - } else { + } + else + { var tag = CreateXElement(textNode); tag.Add(a); parent.Add(tag); } } - + return; } - - if (node.ChildNodes.Count > 0) { + + if (node.ChildNodes.Count > 0) + { var section = CreateXElement(_map.GetValueOrDefault(node.Name, "p")); - foreach (var child in node.ChildNodes) { + foreach (var child in node.ChildNodes) + { ProcessSection(IsTextNode(node) ? parent : section, child); } - if (!section.IsEmpty) { + if (!section.IsEmpty) + { parent.Add(section); } return; } - - if (node.Name == "img") { - if (node.Attributes["src"] == null) { + + if (node.Name == "img") + { + if (node.Attributes["src"] == null) + { return; } - + var imageElem = CreateXElement("image"); imageElem.SetAttributeValue(_xlink + "href", "#" + node.Attributes["src"].Value); parent.Add(imageElem); @@ -301,50 +360,61 @@ private void ProcessSection(XElement parent, HtmlNode node, string textNode = "" } var nodeText = node.InnerText.HtmlDecode().CleanInvalidXmlChars(); - if (node.InnerText.StartsWith(" ")) { + if (node.InnerText.StartsWith(" ")) + { nodeText = " " + nodeText; } - - if (node.InnerText.EndsWith(" ")) { + + if (node.InnerText.EndsWith(" ")) + { nodeText += " "; } - - if (IsTextNode(node)) { - if (string.IsNullOrWhiteSpace(textNode)) { + + if (IsTextNode(node)) + { + if (string.IsNullOrWhiteSpace(textNode)) + { parent.Add(new XText(nodeText)); - } else { + } + else + { var tag = CreateXElement(textNode); tag.Value = nodeText; parent.Add(tag); } - + return; } - if (_map.TryGetValue(node.Name, out var fb2Tag)) { + if (_map.TryGetValue(node.Name, out var fb2Tag)) + { var tag = CreateXElement(fb2Tag); tag.Value = nodeText; parent.Add(tag); - } else { + } + else + { parent.Add(nodeText); Logger.LogInformation(node.Name); } } - private XElement GetDateElement(DateTime date) { + private XElement GetDateElement(DateTime date) + { var today = date.ToString("yyyy-MM-dd"); - + var dateElem = CreateXElement("date"); dateElem.SetAttributeValue("value", today); dateElem.Value = today; - + return dateElem; } - - protected override async Task BuildInternal(Book book, string fileName) { + + protected override async Task BuildInternal(Book book, string fileName) + { await using var file = File.Create(fileName); - + AddAuthor(book.Author); AddCoAuthors(book.CoAuthors); WithBookUrl(book.Url); @@ -354,6 +424,57 @@ protected override async Task BuildInternal(Book book, string fileName) { WithLang(book.Lang); WithSeria(book.Seria); WithChapters(book.Chapters); + + _documentInfo.Add(GetDateElement(DateTime.Today)); + + var programUsed = CreateXElement("program-used"); + programUsed.Value = "Elib2Ebook"; + _documentInfo.Add(programUsed); + + _description.Add(_titleInfo); + _description.Add(_documentInfo); + + if( !string.IsNullOrEmpty(_customInfo.Value) ) + { + _description.Add(_customInfo); + } + + var xws = new XmlWriterSettings + { + Async = true, + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineHandling = NewLineHandling.Replace, + NewLineChars = "\r\n", + }; + + await using var writer = XmlWriter.Create(file, xws); + var cancellationToken = new CancellationToken(); + + await writer.WriteStartElementAsync(string.Empty, "FictionBook", _ns.NamespaceName); + await writer.WriteAttributeStringAsync("xmlns", "l", null, _xlink.NamespaceName); + + await _description.WriteToAsync(writer, cancellationToken); + await _body.WriteToAsync(writer, cancellationToken); + foreach (var image in _images) + { + await WriteBinary(writer, image); + } + + await writer.WriteEndElementAsync(); + await writer.FlushAsync(); + } + + protected override async Task SplitBuild(Book book, string fileName) + { + AddAuthor(book.Author); + AddCoAuthors(book.CoAuthors); + WithBookUrl(book.Url); + WithTitle(book.Title); + WithAnnotation(book.Annotation); + WithCover(book.Cover); + WithLang(book.Lang); + WithSeria(book.Seria); _documentInfo.Add(GetDateElement(DateTime.Today)); @@ -372,19 +493,91 @@ protected override async Task BuildInternal(Book book, string fileName) { NewLineChars = "\r\n", }; + if (Options.SplitVolumes && Options.SplitChapters) + { + foreach (var volumeNumber in book.Volumes) + { + var volume_name = $"{fileName}.v{volumeNumber}.{Extension}"; + await using var volume_file = File.Create(volume_name); + + var selected_chapters = book.Chapters.Where(c => c.VolumeNumber == volumeNumber); + + foreach (var chapter in selected_chapters) + { + var chapter_name = $"{fileName}.v{chapter.VolumeNumber}.c{chapter.ChapterNumber}.{Extension}"; + + await using var chapter_file = File.Create(chapter_name); + + await BuildPart([chapter], chapter_file, xws); + + Logger.LogInformation($"Книга {chapter_name} успешно сохранена"); + + chapter_file.Dispose(); + } + + await BuildPart(selected_chapters, volume_file, xws); + Logger.LogInformation($"Книга {volume_name} успешно сохранена"); + volume_file.Dispose(); + } + } + else if (Options.SplitVolumes) + { + foreach (var volumeNumber in book.Volumes) + { + var volume_name = $"{fileName}.v{volumeNumber}.{Extension}"; + + await using var volume_file = File.Create(volume_name); + + var selected_chapters = book.Chapters.Where(c => c.VolumeNumber == volumeNumber); + + await BuildPart(selected_chapters, volume_file, xws); + + Logger.LogInformation($"Книга {volume_name} успешно сохранена"); + + volume_file.Dispose(); + } + } + else if (Options.SplitChapters) + { + foreach (var chapter in book.Chapters) + { + var chapter_name = $"{fileName}.v{chapter.VolumeNumber}.c{chapter.ChapterNumber}.{Extension}"; + await using var chapter_file = File.Create(chapter_name); + + await BuildPart([chapter], chapter_file, xws); + + Logger.LogInformation($"Книга {chapter_name} успешно сохранена"); + + chapter_file.Dispose(); + } + } + else + { + await BuildInternal(book, fileName); + } + } + + protected async Task BuildPart(IEnumerable chapters, FileStream file, XmlWriterSettings xws) + { + WithChapters(chapters); + await using var writer = XmlWriter.Create(file, xws); var cancellationToken = new CancellationToken(); - + await writer.WriteStartElementAsync(string.Empty, "FictionBook", _ns.NamespaceName); await writer.WriteAttributeStringAsync("xmlns", "l", null, _xlink.NamespaceName); await _description.WriteToAsync(writer, cancellationToken); await _body.WriteToAsync(writer, cancellationToken); - foreach (var image in _images) { + foreach (var image in _images) + { await WriteBinary(writer, image); } await writer.WriteEndElementAsync(); await writer.FlushAsync(); + + _body.RemoveNodes(); + _images.Clear(); } } \ No newline at end of file diff --git a/Core/Logic/Getters/AuthorTodayGetter.cs b/Core/Logic/Getters/AuthorTodayGetter.cs index 8eb6c4d1..dc39d1c1 100644 --- a/Core/Logic/Getters/AuthorTodayGetter.cs +++ b/Core/Logic/Getters/AuthorTodayGetter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -7,6 +8,7 @@ using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Core.Configs; using Core.Extensions; @@ -69,6 +71,41 @@ public override async Task Authorize() { return; } + // АТ очень не любит много авторизовывать + // поэтому пришлось добавить кеширование токенов + const string directory = "ATCache"; + + if (!Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + var saveCreds = $"{directory}/{Config.Options.Login.RemoveInvalidChars()}"; + if (File.Exists(saveCreds)) { + var timeDiff = DateTime.Now - File.GetLastWriteTime(saveCreds); + if ( timeDiff.TotalHours < 24 ) + { + var savedAuth = await File.ReadAllTextAsync(saveCreds).ContinueWith(t => t.Result.Deserialize()); + Config.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedAuth.Token); + + try + { + var checkResponse = await Config.Client.SendWithTriesAsync(() => GetDefaultMessage(ApiUrl.MakeRelativeUri("/v1/account/current-user"), _apiUrl)); + var checkUser = await checkResponse.Content.ReadFromJsonAsync(); + UserId = checkUser!.Id.ToString(); + } + catch (System.Exception){} + + if( !string.IsNullOrWhiteSpace(UserId) ) + { + return; + } + } + else + { + File.Delete(saveCreds); + } + } + var response = await Config.Client.SendAsync(GetDefaultMessage(ApiUrl.MakeRelativeUri("/v1/account/login-by-password"), _apiUrl, JsonContent.Create(new { Config.Options.Login, Config.Options.Password }))); var data = await response.Content.ReadFromJsonAsync(); @@ -82,6 +119,7 @@ public override async Task Authorize() { } else { throw new Exception($"Не удалось авторизоваться. {data?.Message}"); } + await File.WriteAllTextAsync(saveCreds, JsonSerializer.Serialize(data)); } public override async Task Get(Uri url) { @@ -183,7 +221,6 @@ private async Task> FillChapters(AuthorTodayBookDetails boo foreach (var atChapter in await GetChapters(book)) { var title = atChapter.Title.ReplaceNewLine(); - Config.Logger.LogInformation($"Загружаю главу {title.CoverQuotes()}"); var chapter = new Chapter { Title = title @@ -204,13 +241,17 @@ private async Task> FillChapters(AuthorTodayBookDetails boo private async Task> GetChapters(AuthorTodayBookDetails book) { var result = new List(); - foreach (var chunk in book.Chapters.Where(c => !c.IsDraft).OrderBy(c => c.SortOrder).Chunk(100)) { - var ids = string.Join("&", chunk.Select((c, i) => $"ids[{i}]={c.Id}")); - var uri = ApiUrl.MakeRelativeUri($"/v1/work/{book.Id}/chapter/many-texts?{ids}"); + foreach (var chapter in book.Chapters.Where(c => !c.IsDraft && c.isAvailable).OrderBy(c => c.SortOrder)) { + var uri = ApiUrl.MakeRelativeUri($"/v1/work/{book.Id}/chapter/{chapter.Id}/text"); + Config.Logger.LogInformation($"Загружаю главу {chapter.Title.CoverQuotes()}"); var response = await Config.Client.SendWithTriesAsync(() => GetDefaultMessage(uri, _apiUrl)); - var chapters = await response.Content.ReadFromJsonAsync(); - if (chapters != default) { - result.AddRange(chapters.Where(c => c.Code != "NotFound")); + var rchapter = await response.Content.ReadFromJsonAsync(); + if (rchapter != default && rchapter.Text != "" ) { + Config.Logger.LogInformation($"Загружена глава {rchapter.Title.CoverQuotes()}"); + chapter.Text = rchapter.Text; + chapter.Key = rchapter.Key; + chapter.IsSuccessful = true; + result.Add(chapter); } } diff --git a/Core/Logic/Getters/BoovelGetter.cs b/Core/Logic/Getters/BoovelGetter.cs index 731a56f9..3c2b292d 100644 --- a/Core/Logic/Getters/BoovelGetter.cs +++ b/Core/Logic/Getters/BoovelGetter.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; @@ -92,11 +95,37 @@ private async Task GetChapter(UrlChapter urlChapter) { var output = new MemoryStream(); await cs.CopyToAsync(output); - return Encoding.UTF8.GetString(output.ToArray()).AsHtmlDoc(); + return FilterChapterContent( Encoding.UTF8.GetString(output.ToArray()) ); + } + + private static HtmlDocument FilterChapterContent(string data) + { + var sb = new StringBuilder(); + + var doc = data.AsHtmlDoc(); + + foreach (var node in doc.QuerySelectorAll("> *")) + { + var tag = node.Name; + var value = node.QuerySelector("boovell")?.InnerHtml; + if( tag != "img" && tag != "br" ) + { + if ( node.GetType() != typeof(HtmlTextNode) && node.GetType() != typeof(HtmlCommentNode) ) + { + sb.Append($"<{tag}>{value}"); + } + } + else + { + sb.Append(node?.OuterHtml); + } + } + + return sb.AsHtmlDoc(); } private Task GetCover(HtmlDocument doc, Uri bookUri) { - var imagePath = doc.QuerySelector("img.wp-post-image")?.Attributes["src"]?.Value; + var imagePath = doc.QuerySelector("a.story__thumbnail-href")?.Attributes["href"]?.Value; return !string.IsNullOrWhiteSpace(imagePath) ? SaveImage(bookUri.MakeRelativeUri(imagePath)) : Task.FromResult(default(TempFile)); } } \ No newline at end of file diff --git a/Core/Logic/Getters/LibSocial/NewLibSocialGetterBase.cs b/Core/Logic/Getters/LibSocial/NewLibSocialGetterBase.cs index d4896844..853fc7fa 100644 --- a/Core/Logic/Getters/LibSocial/NewLibSocialGetterBase.cs +++ b/Core/Logic/Getters/LibSocial/NewLibSocialGetterBase.cs @@ -84,7 +84,13 @@ private static string GetRandom(int length) { } public override async Task Authorize() { - if (!Config.HasCredentials) { + if (string.IsNullOrEmpty(Config.Options.Token) && !Config.HasCredentials ) { + return; + } + + if (!string.IsNullOrEmpty(Config.Options.Token)) { + Config.Logger.LogInformation("Подставлен токен"); + Config.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Config.Options.Token); return; } @@ -178,6 +184,8 @@ public override async Task Get(Uri url) { var details = await GetBookDetails(url); var book = new Book(url) { + SupportSplitVolumes = true, + SupportSplitChapters = true, Cover = await GetCover(details), Chapters = await FillChapters(details, bid), Title = string.IsNullOrWhiteSpace(details.Data.RusName) ? details.Data.Name : details.Data.RusName, @@ -266,7 +274,9 @@ private async Task> FillChapters(RanobeLibBookDetails book, Config.Logger.LogInformation($"Загружаю главу {title.CoverQuotes()}"); var chapter = new Chapter { - Title = title + Title = title, + VolumeNumber = socialChapter.Volume, + ChapterNumber = socialChapter.Number, }; var chapterDoc = await GetChapter(book, socialChapter, bid); diff --git a/Core/Logic/Getters/Litnet/LitnetGetterBase.cs b/Core/Logic/Getters/Litnet/LitnetGetterBase.cs index 2eb0870f..9dee2b76 100644 --- a/Core/Logic/Getters/Litnet/LitnetGetterBase.cs +++ b/Core/Logic/Getters/Litnet/LitnetGetterBase.cs @@ -7,6 +7,7 @@ using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using System.Web; using Core.Configs; @@ -28,7 +29,7 @@ public abstract class LitnetGetterBase(BookGetterConfig config) : GetterBase(con protected virtual Uri SiteIp => SystemUrl; - private static readonly string DeviceId = Guid.NewGuid().ToString().ToUpper(); + private static string DeviceId = Guid.NewGuid().ToString().ToUpper(); private const string SECRET = "14a6579a984b3c6abecda6c2dfa83a64"; private string _token { get; set; } @@ -66,15 +67,48 @@ private static string GetSign(string token) { public override async Task Authorize() { var path = Config.HasCredentials ? "user/find-by-login" : "registration/registration-by-device"; + const string directory = "LNCache"; + + if (!Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + var saveCreds = $"{directory}/{Config.Options.Login.RemoveInvalidChars()}"; + if (Config.HasCredentials) { + if (File.Exists(saveCreds)) { + var timeDiff = DateTime.Now - File.GetLastWriteTime(saveCreds); + if ( timeDiff.TotalHours < 2 ) + { + var savedAuth = await File.ReadAllTextAsync(saveCreds).ContinueWith(t => t.Result.Deserialize()); + _token = savedAuth.Token; + DeviceId = savedAuth.DeviceID; + + if( !string.IsNullOrWhiteSpace(_token) ) + { + return; + } + } + else + { + File.Delete(saveCreds); + } + } + } + var url = ApiIp.MakeRelativeUri($"v1/{path}?login={HttpUtility.UrlEncode(Config.Options.Login?.TrimStart('+') ?? string.Empty)}&password={HttpUtility.UrlEncode(Config.Options.Password)}&app=android&device_id={DeviceId}&sign={GetSign(string.Empty)}"); var data = await GetApiData(url); if (!string.IsNullOrWhiteSpace(data?.Token)) { Config.Logger.LogInformation("Успешно авторизовались"); + data.DeviceID = DeviceId; _token = data.Token; } else { throw new Exception($"Не удалось авторизоваться. {data?.Error}"); } + + if (Config.HasCredentials) { + await File.WriteAllTextAsync(saveCreds, JsonSerializer.Serialize(data)); + } } private async Task GetBook(string token, string bookId) { diff --git a/Core/Logic/Getters/LitresGetter.cs b/Core/Logic/Getters/LitresGetter.cs index 78c3e7b9..4cee5056 100644 --- a/Core/Logic/Getters/LitresGetter.cs +++ b/Core/Logic/Getters/LitresGetter.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; @@ -16,7 +17,6 @@ using Core.Types.Book; using Core.Types.Common; using Core.Types.Litres; -using Core.Types.Litres.Requests; using Core.Types.Litres.Response; using HtmlAgilityPack; using HtmlAgilityPack.CssSelectors.NetCore; @@ -29,6 +29,7 @@ public class LitresGetter(BookGetterConfig config) : GetterBase(config) { private const string APP = "13"; protected override Uri SystemUrl => new("https://www.litres.ru"); + protected static Uri ApiUrl => new("https://api.litres.ru"); private static Uri GetShortUri(LitresArt art) => new($"https://catalit.litres.ru/pub/t/{art.Id}.fb3"); @@ -70,10 +71,17 @@ private Uri GetFullUri(LitresArt art, string path, LitresFile file, string host private LitresMe _me; public override async Task Authorize() { - if (!Config.HasCredentials) { + if (string.IsNullOrEmpty(Config.Options.Token) && !Config.HasCredentials ) { Config.Client.DefaultRequestHeaders.Add("Session-Id", _authData.Sid); return; } + + if (!string.IsNullOrEmpty(Config.Options.Token)) { + Config.Logger.LogInformation("Подставлен токен"); + _authData.Sid = Config.Options.Token; + Config.Client.DefaultRequestHeaders.Add("Session-Id", Config.Options.Token); + return; + } // Литрес очень не любит много авторизовывать // поэтому пришлось добавить кеширование токенов @@ -85,20 +93,46 @@ public override async Task Authorize() { var saveCreds = $"{directory}/{Config.Options.Login.RemoveInvalidChars()}"; if (File.Exists(saveCreds)) { - _authData = await File.ReadAllTextAsync(saveCreds).ContinueWith(t => t.Result.Deserialize()); - Config.Client.DefaultRequestHeaders.Add("Session-Id", _authData.Sid); - - var checkResponse = await Config.Client.GetFromJsonAsync>("https://api.litres.ru/foundation/api/users/me/detailed"); - if (checkResponse?.Payload?.Data != default) { - _me = checkResponse.Payload.Data; - return; + var timeDiff = DateTime.Now - File.GetLastWriteTime(saveCreds); + if ( timeDiff.TotalHours < 4 ) + { + _authData = await File.ReadAllTextAsync(saveCreds).ContinueWith(t => t.Result.Deserialize());; + Config.Client.DefaultRequestHeaders.Add("Session-Id", _authData.Sid); + + var checkResponse = await Config.Client.GetFromJsonAsync>(ApiUrl.MakeRelativeUri("/foundation/api/users/me/detailed")); + if (checkResponse?.Payload?.Data != default) { + _me = checkResponse.Payload.Data; + return; + } + } + else + { + File.Delete(saveCreds); } } - var payload = LitresPayload.Create(DateTime.Now, string.Empty, SECRET_KEY, APP); - payload.Requests.Add(new LitresAuthRequest(Config.Options.Login, Config.Options.Password)); - - _authData = await GetResponse(payload); + var auth = new Dictionary { + { "login", Config.Options.Login }, + { "password", Config.Options.Password } + }; + var authPayload = new StringContent( JsonSerializer.Serialize(auth) ); + authPayload.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + Config.Client.DefaultRequestHeaders.Remove("app-id"); + Config.Client.DefaultRequestHeaders.Add("app-id","115"); + var authResponse = await Config.Client.PostWithTriesAsync(ApiUrl.MakeRelativeUri("/foundation/api/auth/login"), authPayload); + Config.Client.DefaultRequestHeaders.Remove("app-id"); + + _authData = await authResponse.Content.ReadAsStringAsync().ContinueWith(t => + { + var authData = t.Result.Deserialize(); + var newAuthData = new LitresAuthResponseData(); + newAuthData.Success = authData.Status==200; + newAuthData.Sid = authData.Payload.Data.Sid; + newAuthData.ErrorMessage = authData.Error; + + return newAuthData; + }); if (!_authData.Success) { throw new Exception($"Не удалось авторизоваться. {_authData.ErrorMessage}"); @@ -116,7 +150,9 @@ public override async Task Authorize() { private async Task GetResponse(LitresPayload payload) { var resp = await Config.Client.PostWithTriesAsync("https://catalit.litres.ru/catalitv2".AsUri(), CreatePayload(payload)); - return await resp.Content.ReadAsStringAsync().ContinueWith(t => t.Result.Deserialize>().Data); + return await resp.Content.ReadAsStringAsync().ContinueWith(t => { + return t.Result.Deserialize>().Data; + }); } private async Task GetResponse(Uri url) { @@ -125,7 +161,9 @@ private async Task GetResponse(Uri url) { return default; } - return await resp.Content.ReadAsStringAsync().ContinueWith(t => t.Result.Deserialize>().Payload.Data); + return await resp.Content.ReadAsStringAsync().ContinueWith(t => { + return t.Result.Deserialize>().Payload.Data; + }); } private static FormUrlEncodedContent CreatePayload(LitresPayload payload) { @@ -139,7 +177,12 @@ private static FormUrlEncodedContent CreatePayload(LitresPayload payload) { public override async Task Get(Uri url) { var bookId = GetBookId(url); - var art = await GetResponse($"https://api.litres.ru/foundation/api/arts/{bookId}".AsUri()); + var art = new LitresArt(); + try + { + art = await GetResponse(ApiUrl.MakeRelativeUri($"/foundation/api/arts/{bookId}")); + } + catch (System.Exception){} var book = new Book(SystemUrl.MakeRelativeUri(bookId)) { Cover = await GetCover(art.Cover), @@ -177,7 +220,7 @@ private async Task FillAdditional(Book book, LitresArt art) { var type = art.ArtType == LitresArtTypeEnum.Audio ? AdditionalTypeEnum.Audio : AdditionalTypeEnum.Books; if (Config.Options.HasAdditionalType(type)) { - var files = await GetResponse($"https://api.litres.ru/foundation/api/arts/{art.Id}/files/grouped".AsUri()); + var files = await GetResponse(ApiUrl.MakeRelativeUri($"/foundation/api/arts/{art.Id}/files/grouped")); foreach (var file in files.SelectMany(f => f.Files)) { using var fileResponse = await GetFileResponse(art, file); @@ -234,7 +277,7 @@ private Seria GetSeria(LitresArt art) { private async Task GetAuthor(LitresArt art) { var person = art.Persons.FirstOrDefault(a => a.Role == "author"); - var author = person == null ? default : await GetResponse>($"https://api.litres.ru/foundation/api/persons/{person.Id}".AsUri()); + var author = person == null ? default : await GetResponse>(ApiUrl.MakeRelativeUri($"/foundation/api/persons/{person.Id}")); return author == default ? new Author("Litres") : new Author(author.FullName, SystemUrl.MakeRelativeUri(author.Url)); @@ -243,7 +286,7 @@ private async Task GetAuthor(LitresArt art) { private async Task> GetCoAuthors(LitresArt art) { var result = new List(); foreach (var person in art.Persons.Where(a => a.Role == "author").Skip(1)) { - var author = person == null ? default : await GetResponse>($"https://api.litres.ru/foundation/api/persons/{person.Id}".AsUri()); + var author = person == null ? default : await GetResponse>(ApiUrl.MakeRelativeUri($"/foundation/api/persons/{person.Id}")); if (author != default) { result.Add(new Author(author.FullName, SystemUrl.MakeRelativeUri(author.Url))); } diff --git a/Core/Types/AuthorToday/AuthorTodayChapter.cs b/Core/Types/AuthorToday/AuthorTodayChapter.cs index 6b1ac2f3..f7e69637 100644 --- a/Core/Types/AuthorToday/AuthorTodayChapter.cs +++ b/Core/Types/AuthorToday/AuthorTodayChapter.cs @@ -28,6 +28,9 @@ public class AuthorTodayChapter { [JsonPropertyName("IsDraft")] public bool IsDraft { get; set; } + + [JsonPropertyName("isAvailable")] + public bool isAvailable { get; set; } [JsonPropertyName("code")] public string Code { get; set; } diff --git a/Core/Types/Book/Book.cs b/Core/Types/Book/Book.cs index 02603f64..aa4af7af 100644 --- a/Core/Types/Book/Book.cs +++ b/Core/Types/Book/Book.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Collections.Generic; using Core.Types.Common; @@ -40,6 +41,9 @@ public class Book : IDisposable { /// public IEnumerable Chapters { get; set; } = new List(); +/// Тома + public IEnumerable Volumes => Chapters.Select(c => c.VolumeNumber).Distinct(); + /// /// Url расположения книги /// @@ -55,6 +59,11 @@ public class Book : IDisposable { /// public string Lang { get; set; } = "ru"; + public bool SupportSplitVolumes { get; set; } = false; + public bool SupportSplitChapters { get; set; } = false; + + public bool SupportSplitting => SupportSplitVolumes || SupportSplitChapters; + public Book(Uri url) { Url = url; } diff --git a/Core/Types/Book/Chapter.cs b/Core/Types/Book/Chapter.cs index cbdab9ab..b8557b20 100644 --- a/Core/Types/Book/Chapter.cs +++ b/Core/Types/Book/Chapter.cs @@ -14,6 +14,16 @@ public class Chapter { /// public string Content { get; set; } + /// + /// Номер тома + /// + public string VolumeNumber { get; set; } + + /// + /// Номер главы + /// + public string ChapterNumber { get; set; } + /// /// Изображения из части /// diff --git a/Core/Types/Litnet/LitnetAuthResponse.cs b/Core/Types/Litnet/LitnetAuthResponse.cs index e8dc4483..a5fe8b12 100644 --- a/Core/Types/Litnet/LitnetAuthResponse.cs +++ b/Core/Types/Litnet/LitnetAuthResponse.cs @@ -8,4 +8,7 @@ public class LitnetAuthResponse { [JsonPropertyName("token")] public string Token { get; set; } + + [JsonPropertyName("device_id")] + public string DeviceID { get; set; } } \ No newline at end of file diff --git a/Core/Types/Litres/Response/LitresWebAuthResponse.cs b/Core/Types/Litres/Response/LitresWebAuthResponse.cs new file mode 100644 index 00000000..7c407e34 --- /dev/null +++ b/Core/Types/Litres/Response/LitresWebAuthResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Core.Types.Litres.Response; +public class LitresWebAuthPayloadData +{ + [JsonPropertyName("sid")] + public string Sid { get; set; } +} + +public class LitresWebAuthPayload +{ + [JsonPropertyName("data")] + public LitresWebAuthPayloadData Data { get; set; } +} + +public class LitresWebAuthResponse +{ + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("payload")] + public LitresWebAuthPayload Payload { get; set; } + + [JsonPropertyName("error")] + public string Error { get; set; } = ""; +} + diff --git a/Elib2EbookCli/Program.cs b/Elib2EbookCli/Program.cs index 7e1b7917..9d748af9 100644 --- a/Elib2EbookCli/Program.cs +++ b/Elib2EbookCli/Program.cs @@ -51,7 +51,7 @@ await parserResult await getter.Init(); await getter.Authorize(); } catch (Exception ex) { - logger.LogInformation($"Ошибка авторизации или инициализации. {ex.Message}"); + logger.LogInformation($"Ошибка авторизации или инициализации. {ex.Message} \n {ex.ToString()}"); return; }