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("Неизвестный тип")
};
}
}
есть еще что подправить?
NewLibSocialGetterBase.cs
RanobeLibGetter.cs
есть еще что подправить?