Skip to content

Исправления для ранобелиба #176

Description

@zverik-a

NewLibSocialGetterBase.cs

using System;
using System.Collections.Generic;
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;
using System.Threading.Tasks;
using Core.Configs;
using Core.Extensions;
using Core.Types.Book;
using Core.Types.Common;
using Core.Types.SocialLib;
using HtmlAgilityPack;
using HtmlAgilityPack.CssSelectors.NetCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;

namespace Core.Logic.Getters.LibSocial;

public abstract class NewLibSocialGetterBase(BookGetterConfig config) : GetterBase(config) {

    private static Uri AuthHost => new("https://auth.lib.social/");

    private static Uri ApiHost => new("https://api.cdnlibs.org/");

    private static Uri BaseImageHost => new("https://cover.imglib.info/");
    
    protected virtual Uri ImagesHost => BaseImageHost;

    private const string ALPHABET_BASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    private const string ALPHABET_CHALLENGE = ALPHABET_BASE + "-_";

    protected abstract int SiteId { get; }

public override async Task Init() {
    await base.Init();

    // Очищаем старые заголовки, если они есть
    Config.Client.DefaultRequestHeaders.Clear();

    // Устанавливаем заголовки реального браузера
    Config.Client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36");
    Config.Client.DefaultRequestHeaders.Add("Accept", "application/json, text/plain, */*");
    Config.Client.DefaultRequestHeaders.Add("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7");
    Config.Client.DefaultRequestHeaders.Add("Referer", "https://ranobelib.me/");
    Config.Client.DefaultRequestHeaders.Add("Site-Id", SiteId.ToString());

    // Таймзона (оставляем как было, но в блоке try)
    try {
        var timeZoneId = TimeZoneInfo.Local.Id;
        Config.Client.DefaultRequestHeaders.Add("Client-Time-Zone", timeZoneId);
    } catch { /* игнорируем */ }
}

    private static string TrimForLog(string value, int maxLength = 300) {
        if (string.IsNullOrWhiteSpace(value)) {
            return string.Empty;
        }

        var trimmed = value.Trim();
        return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength] + "…";
    }
    
    private static string Challenge(string str) {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(str).AsSpan(0, Encoding.UTF8.GetByteCount(str)));
        var result = string.Empty;
        
        for (var t = 0; t < bytes.Length; t += 3) {
            result += ALPHABET_CHALLENGE[bytes[t] >> 2];
            result += ALPHABET_CHALLENGE[(bytes[t] & 3) << 4 | bytes[t + 1] >> 4];
            result += ALPHABET_CHALLENGE[(bytes[t + 1] & 15) << 2 | (t + 2 < bytes.Length ? bytes[t + 2] : 0) >> 6];
            result += ALPHABET_CHALLENGE[(t + 2 < bytes.Length ? bytes[t + 2] : 0) & 63];
        }

        return (bytes.Length % 3) switch {
            2 => result[..^1],
            1 => result[..^2],
            _ => result
        };
    }

    private static string GetRandom(int length) {
        return Enumerable
            .Repeat(0, length)
            .Aggregate(string.Empty, (c, _) => c + ALPHABET_BASE[new Random().Next(ALPHABET_BASE.Length)]);
    }

    public override async Task Authorize() {
        if (!Config.HasCredentials) {
            return;
        }

        var secret = GetRandom(128);
        var state = GetRandom(40);
        var redirectUri = SystemUrl.MakeRelativeUri("/ru/front/auth/oauth/callback");
        
        var challenge = Challenge(secret);
        var challengeUrl = AuthHost.MakeRelativeUri($"/auth/oauth/authorize?scope=&client_id=1&response_type=code&redirect_uri={redirectUri}&state={state}&code_challenge={challenge}&code_challenge_method=S256&prompt=consent");
        
        var loginForm = await Config.Client.GetHtmlDocWithTriesAsync(challengeUrl);
        var tokenInput = loginForm.QuerySelector("input[name=_token]");
        if (tokenInput == default) {
            throw new Exception("Не удалось получить csrf токен формы авторизации сайта (возможно, заблокировал DDoS-Guard)");
        }

        var payload = new Dictionary<string, string> {
            { "_token", tokenInput.Attributes["value"].Value },
            { "login", Config.Options.Login },
            { "password", Config.Options.Password },
        };

        await Config.Client.PostHtmlDocWithTriesAsync(AuthHost.MakeRelativeUri("/auth/login"), new FormUrlEncodedContent(payload));
        var login = await Config.Client.GetHtmlDocWithTriesAsync(challengeUrl);
        if (login.QuerySelector(".g-recaptcha") != default) {
            throw new Exception("Авторизация заблокирована капчей. Укажите --flare с адресом FlareSolverr");
        }
        var error = login.QuerySelector(".form-field__error");
        if (error != default && !string.IsNullOrWhiteSpace(error.InnerText)) {
            throw new Exception($"Не удалось авторизоваться. {error.InnerText.Trim()}");
        }

        var postForm = login.QuerySelector("form[method=post]");
        if (postForm == default) {
            throw new Exception("Не удалось получить форму подтверждения OAuth на сайте (проверьте логин/пароль)");
        }

        payload = postForm
            .QuerySelectorAll("input[type=hidden]")
            .ToDictionary(input => input.Attributes["name"].Value, input => input.Attributes["value"].Value);
        
        using var authorize = await Config.Client.PostAsync(AuthHost.MakeRelativeUri(postForm.Attributes["action"].Value), new FormUrlEncodedContent(payload));
        if ((int)authorize.StatusCode == 419) {
            throw new Exception("Авторизация отклонена (код 419). Обычно это означает истекший CSRF токен или необходимость пройти капчу. Попробуйте указать --flare");
        }
        if (authorize == default || authorize.RequestMessage?.RequestUri == default) {
            throw new Exception("Сайт не вернул код авторизации (проверьте логин/пароль)");
        }

        var authCode = authorize.RequestMessage.RequestUri.GetQueryParameter("code");
        if (string.IsNullOrWhiteSpace(authCode)) {
            throw new Exception("Сайт не вернул code после авторизации (проверьте логин/пароль)");
        }

        var tokenResponse = await Config.Client.PostAsync(ApiHost.MakeRelativeUri("/api/auth/oauth/token"), JsonContent.Create(new {
            grant_type = "authorization_code",
            client_id = 1,
            redirect_uri = redirectUri,
            code_verifier = secret,
            code = authCode
        }));

        if (tokenResponse == default) {
            throw new Exception("Не удалось получить ответ от сайта при запросе access token");
        }

        if (tokenResponse.StatusCode != HttpStatusCode.OK) {
            var errorBody = await tokenResponse.Content.ReadAsStringAsync();
            var snippet = string.IsNullOrWhiteSpace(errorBody)
                ? string.Empty
                : $" Ответ сервера: {TrimForLog(errorBody)}";
            throw new Exception($"Сайт не выдал access token. Код {(int)tokenResponse.StatusCode} ({tokenResponse.StatusCode}).{snippet}");
        }

        var token = await tokenResponse.Content.ReadFromJsonAsync<LibSocialToken>();
        if (token == default || string.IsNullOrWhiteSpace(token.AccessToken)) {
            throw new Exception("Сайт не вернул access token. Проверьте корректность логина и пароля");
        }

        Config.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
        Config.Logger.LogInformation("Успешно авторизовались");
    }

    protected override string GetId(Uri url) {
        var id = url.GetSegment(2);
        return id is "book" or "read" or "manga" ? url.GetSegment(3) : id;
    }

    public override async Task<Book> Get(Uri url) {
        var bid = url.GetQueryParameter("bid");
        var details = await GetBookDetails(url);

        var book = new Book(url) {
            Cover = await GetCover(details),
            Chapters = await FillChapters(details, bid),
            Title = string.IsNullOrWhiteSpace(details.Data.RusName) ? details.Data.Name : details.Data.RusName,
            Author = GetAuthor(details),
            CoAuthors = GetCoAuthors(details),
            Annotation = details.Data.Summary
        };

        return book;
    }

    private async Task<RanobeLibBookDetails> GetBookDetails(Uri url) {
        url = ApiHost
            .AppendSegment("api/manga/" + GetId(url))
            .AppendQueryParameter("fields[]", "background")
            .AppendQueryParameter("fields[]", "teams")
            .AppendQueryParameter("fields[]", "authors")
            .AppendQueryParameter("fields[]", "summary")
            .AppendQueryParameter("fields[]", "chap_count");

        var response = await Config.Client.GetWithTriesAsync(url);

        var content = await response.Content.ReadAsStringAsync();
        if (content.Trim().StartsWith("<")) {
            throw new Exception("Сайт вернул HTML вместо данных. Скорее всего, сработал Cloudflare. Попробуйте обновить FlareSolverr.");
        }

        if (response.StatusCode != HttpStatusCode.OK) {
            throw new Exception($"Ошибка загрузки информации о книге. Код {(int)response.StatusCode}.");
        }

        return JsonSerializer.Deserialize<RanobeLibBookDetails>(content);
    }
       /* if (response == default) {
            throw new Exception("Не удалось получить ответ от сайта при загрузке информации о книге");
        }

        if (response.StatusCode != HttpStatusCode.OK) {
            throw new Exception($"Ошибка загрузки информации о книге. Код {(int)response.StatusCode} ({response.StatusCode}).");
        }

        return await response.Content.ReadFromJsonAsync<RanobeLibBookDetails>();
    }*/

    private async Task<IEnumerable<SocialLibBookChapter>> GetToc(RanobeLibBookDetails book, string bid) {
        var url = ApiHost.MakeRelativeUri($"/api/manga/{book.Data.SlugUrl}/chapters");

        Config.Logger.LogInformation("Загружаю оглавление");

        var response = await Config.Client.GetWithTriesAsync(url);
        if (response == default) {
            throw new Exception("Не удалось получить ответ от сайта при загрузке оглавления");
        }

        if (response.StatusCode != HttpStatusCode.OK) {
            throw new Exception($"Ошибка загрузки оглавления. Код {(int)response.StatusCode} ({response.StatusCode}).");
        }

        var result = await response.Content.ReadFromJsonAsync<SocialLibBookChapters>().ContinueWith(t => t.Result.Chapters);
        if (!string.IsNullOrWhiteSpace(bid)) {
            result = result.Where(c => c.Branches.Any(b => b.BranchId.ToString() == bid)).ToList();
        }
        
        return SliceToc(result, c => c.Name);
    }

    private Author GetAuthor(RanobeLibBookDetails details) {
        var author = details.Data.Authors.FirstOrDefault();
        return author == default ? new Author("Ranobelib") : new Author(author.Name, SystemUrl.MakeRelativeUri($"/ru/people/{author.SlugUrl}"));
    }
    
    private IEnumerable<Author> GetCoAuthors(RanobeLibBookDetails details) {
        return details.Data.Authors
            .Skip(1)
            .Select(author => new Author(author.Name, SystemUrl.MakeRelativeUri($"/ru/people/{author.SlugUrl}"))).ToList();
    }
    
    private Task<TempFile> GetCover(RanobeLibBookDetails details) {
        return !string.IsNullOrWhiteSpace(details.Data.Cover.Default) ? SaveImage(details.Data.Cover.Default.AsUri()) : Task.FromResult(default(TempFile));
    }

    protected override HttpRequestMessage GetImageRequestMessage(Uri uri) {
    // Если картинка уже имеет хост imglib или ruranobe, не нужно её насильно перенаправлять на BaseImageHost
    // так как пути на разных поддоменах (cover, media, static) различаются.
    
    var message = base.GetImageRequestMessage(uri);
    
    message.Headers.Remove("Referer");
    message.Headers.TryAddWithoutValidation("Referer", SystemUrl.ToString());
    
    return message; 
    }
/*
    private async Task<IEnumerable<Chapter>> FillChapters(RanobeLibBookDetails book, string bid) {
        var result = new List<Chapter>();
        if (Config.Options.NoChapters) {
            return result;
        }
        
        foreach (var socialChapter in await GetToc(book, bid)) {
            var title = socialChapter.Name.ReplaceNewLine();
            Config.Logger.LogInformation($"Загружаю главу {title.CoverQuotes()}");
            
            var chapter = new Chapter {
                Title = title
            };

            var chapterDoc = await GetChapter(book, socialChapter, bid);
            chapter.Images = await GetImages(chapterDoc, ImagesHost);
            chapter.Content = chapterDoc.DocumentNode.InnerHtml;

            result.Add(chapter);
        }
            
        return result;*/
    private async Task<IEnumerable<Chapter>> FillChapters(RanobeLibBookDetails book, string bid) {
    var toc = (await GetToc(book, bid)).ToList();
    var result = new ConcurrentDictionary<int, Chapter>(); // Используем потокобезопасный словарь
    
    if (Config.Options.NoChapters) {
        return Enumerable.Empty<Chapter>();
    }

    // Ограничиваем количество одновременных запросов (5-8 — оптимально и безопасно)
    var semaphore = new System.Threading.SemaphoreSlim(1);

    var tasks = toc.Select(async (socialChapter, index) => {
        await semaphore.WaitAsync();
        try {
            await Task.Delay(300); 
        
            var title = socialChapter.Name.ReplaceNewLine();
            Config.Logger.LogInformation($"Загружаю главу [{index + 1}/{toc.Count}]: {title.CoverQuotes()}");

            var chapter = new Chapter {
                Title = title
            };

            var chapterDoc = await GetChapter(book, socialChapter, bid);
            
            // Если картинки не нужны, этот метод отработает быстро или вернет пустоту
            chapter.Images = await GetImages(chapterDoc, ImagesHost);
            chapter.Content = chapterDoc.DocumentNode.InnerHtml;

            // Сохраняем по индексу, чтобы порядок глав не перемешался из-за параллелизма
            result.TryAdd(index, chapter);
        }
        finally {
            semaphore.Release();
        }
    });

    await Task.WhenAll(tasks);

    // Возвращаем главы, отсортированные по их исходному порядку
    return result.OrderBy(k => k.Key).Select(v => v.Value);
}

    private async Task<HtmlDocument> GetChapter(RanobeLibBookDetails book, SocialLibBookChapter chapter, string bid) {
        var uri = ApiHost.MakeRelativeUri($"/api/manga/{book.Data.SlugUrl}/chapter?number={chapter.Number}&volume={chapter.Volume}");
        if (!string.IsNullOrWhiteSpace(bid)) {
            uri = uri.AppendQueryParameter("branch_id", bid);
        }
        
        var chapterResponse = await Config.Client.GetFromJsonWithTriesAsync<SocialLibBookChapterResponse>(uri, TimeSpan.FromSeconds(10));
        return ResponseToHtmlDoc(chapterResponse.Data);
    }

    protected abstract HtmlDocument ResponseToHtmlDoc(SocialLibBookChapter chapterResponse);
}

RanobeLibGetter.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Core.Configs;
using Core.Extensions;
using Core.Types.SocialLib;
using HtmlAgilityPack;
using Microsoft.Extensions.Logging;

namespace Core.Logic.Getters.LibSocial; 

public class RanobeLibGetter(BookGetterConfig config) : NewLibSocialGetterBase(config) {
    protected override Uri SystemUrl => new("https://ranobelib.me/");

    protected override int SiteId => 3;
    
    public override bool IsSameUrl(Uri url) {
        return url.Host == "ranobelib.me" || url.Host == "old.ranobelib.me";
    }

   private static readonly Dictionary<string, string> RecursiveTag = new() {
    { "paragraph", "p" },
    { "orderedList", "ol" },
    { "listItem", "li" },
    { "blockquote", "blockquote" },
    { "heading", "h3" }, // Все заголовки внутри глав будут превращаться в h3
};
    
    private static readonly Dictionary<string, string> InlineTag = new() {
        { "horizontalRule", "<hr />" },
        { "hardBreak", "<br />" },
    };
    
    private static readonly Dictionary<string, string> MarkTag = new() {
        { "italic", "i" },
        { "bold", "b" },
        { "underline", "u" },
    };

    private StringBuilder AsHtml(SocialLibBookChapter chapterResponse, IEnumerable<SocialLibChapterContent> contents) {
        var sb = new StringBuilder();

        foreach (var content in contents) {
            if (RecursiveTag.TryGetValue(content.Type, out var tag)) {
                sb.Append(AsHtml(chapterResponse, content.Content).ToString().CoverTag(tag)); 
                continue;
            }
            
            if (InlineTag.TryGetValue(content.Type, out tag)) {
                sb.Append(tag);
                continue;
            }

            switch (content.Type) {
                case "text": {
                    var text = content.Text.HtmlEncode();

                    foreach (var mark in content.Marks) {
                        if (MarkTag.TryGetValue(mark.Type, out tag)) {
                            text = text.CoverTag(tag);
                        } else {
                            Config.Logger.LogInformation($"Неизвестый тип форматирования {mark.Type}");
                        }
                    }

                    sb.Append(text);
                    continue;
                }
                case "image": {
                    if (content.Attrs.TryGetValue("images", out var images)) {
                        foreach (var image in images.Deserialize<Dictionary<string, string>[]>()) {
                            if (!image.TryGetValue("image", out var imageId)) {
                                continue;
                            }
                            
                            var attachment = chapterResponse.Attachments.FirstOrDefault(a => a.Name == imageId);
                            if (attachment == default) {
                                continue;
                            }
                            
                            sb.Append($"<img src=\"{attachment.Url}\" />");
                        }
                    }
                
                    continue;
                }
                default:
                    Config.Logger.LogInformation($"Неизвестый тип {content.Type}");
                    break;
            }
        }

        return sb;
    }
    
    protected override HtmlDocument ResponseToHtmlDoc(SocialLibBookChapter chapterResponse) {
        return chapterResponse.Content switch {
            JsonValue e => e.GetValue<string>().AsHtmlDoc(),
            JsonObject o => AsHtml(chapterResponse, o.Deserialize<SocialLibChapterContent>().Content).AsHtmlDoc(),
            _ => throw new Exception("Неизвестный тип")
        };
    }
}

есть еще что подправить?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions