Add MangaBuff (#3891)

* Add MangaBuff

* style

* Update src/ru/mangabuff/src/eu/kanade/tachiyomi/extension/ru/mangabuff/MangaBuff.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* PR comments

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
Vetle Ledaal 2024-07-07 16:32:10 +02:00 committed by Draff
parent 5b920b207a
commit 121f0591db
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
10 changed files with 698 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.kanade.tachiyomi.extension.ru.mangabuff.MangaBuffUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="mangabuff.ru"
android:pathPattern="/manga/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'MangaBuff'
extClass = '.MangaBuff'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,343 @@
package eu.kanade.tachiyomi.extension.ru.mangabuff
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class MangaBuff : ParsedHttpSource() {
override val baseUrl = "https://mangabuff.ru"
override val lang = "ru"
override val name = "MangaBuff"
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::tokenInterceptor)
.build()
private val json: Json by injectLazy()
// From Akuma - CSRF token
private var storedToken: String? = null
private fun tokenInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.method == "POST" && request.header("X-CSRF-TOKEN") == null) {
val newRequest = request.newBuilder()
val token = getToken()
val response = chain.proceed(
newRequest
.addHeader("X-CSRF-TOKEN", token)
.build(),
)
if (response.code == 419) {
response.close()
storedToken = null // reset the token
val newToken = getToken()
return chain.proceed(
newRequest
.addHeader("X-CSRF-TOKEN", newToken)
.build(),
)
}
return response
}
val response = chain.proceed(request)
if (response.header("Content-Type")?.contains("text/html") != true) {
return response
}
storedToken = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string())
.selectFirst("head meta[name*=csrf-token]")
?.attr("content")
return response
}
private fun getToken(): String {
if (storedToken.isNullOrEmpty()) {
val request = GET(baseUrl, headers)
client.newCall(request).execute().close() // updates token in interceptor
}
return storedToken!!
}
// Popular
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", SortFilter.POPULAR)
override fun popularMangaSelector() = searchMangaSelector()
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
// Latest
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", SortFilter.LATEST)
override fun latestUpdatesSelector() = searchMangaSelector()
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
// Search
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
if (!query.startsWith(SEARCH_PREFIX)) {
return super.fetchSearchManga(page, query, filters)
}
val request = GET("$baseUrl/manga/${query.substringAfter(SEARCH_PREFIX)}")
return client.newCall(request).asObservableSuccess().map { response ->
val details = mangaDetailsParse(response)
details.setUrlWithoutDomain(request.url.toString())
MangasPage(listOf(details), false)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotEmpty()) {
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
addQueryParameter("q", query)
if (page != 1) addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
val url = "$baseUrl/manga".toHttpUrl().newBuilder().apply {
(filters.find { it is GenreFilter } as? GenreFilter)?.let { filter ->
filter.included?.forEach { addQueryParameter("genres[]", it) }
}
(filters.find { it is TypeFilter } as? TypeFilter)?.let { filter ->
filter.included?.forEach { addQueryParameter("type_id[]", it) }
}
(filters.find { it is TagFilter } as? TagFilter)?.let { filter ->
filter.included?.forEach { addQueryParameter("tags[]", it) }
}
(filters.find { it is StatusFilter } as? StatusFilter)?.let { filter ->
filter.checked?.forEach { addQueryParameter("status_id[]", it) }
}
(filters.find { it is AgeFilter } as? AgeFilter)?.let { filter ->
filter.checked?.forEach { addQueryParameter("age_rating[]", it) }
}
(filters.find { it is RatingFilter } as? RatingFilter)?.let { filter ->
filter.checked?.forEach { addQueryParameter("rating[]", it) }
}
(filters.find { it is YearFilter } as? YearFilter)?.let { filter ->
filter.checked?.forEach { addQueryParameter("year[]", it) }
}
(filters.find { it is ChapterCountFilter } as? ChapterCountFilter)?.let { filter ->
filter.checked?.forEach { addQueryParameter("chapters[]", it) }
}
(filters.find { it is GenreFilter } as? GenreFilter)?.let { filter ->
filter.excluded?.forEach { addQueryParameter("without_genres[]", it) }
}
(filters.find { it is TypeFilter } as? TypeFilter)?.let { filter ->
filter.excluded?.forEach { addQueryParameter("without_type_id[]", it) }
}
(filters.find { it is TagFilter } as? TagFilter)?.let { filter ->
filter.excluded?.forEach { addQueryParameter("without_tags[]", it) }
}
(filters.find { it is SortFilter } as? SortFilter)?.let { filter ->
addQueryParameter("sort", filter.selected)
}
if (page != 1) addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
override fun searchMangaSelector() = ".cards .cards__item"
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
title = element.selectFirst(".cards__name")!!.text()
val slug = "$baseUrl$url".toHttpUrl().pathSegments.last()
thumbnail_url = "$baseUrl/img/manga/posters/$slug.jpg"
}
override fun searchMangaNextPageSelector() =
".pagination .pagination__button--active + li:not(:last-child)"
// Details
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
title = document.selectFirst("h1, .manga__name, .manga-mobile__name")!!.text()
description = buildString {
document
.selectFirst(".manga__description")
?.text()
?.also { append(it) }
document // rating%
.selectFirst(".manga__rating")
?.text()
?.toDoubleOrNull()
?.let { it / 10.0 }
?.also {
if (isNotEmpty()) append("\n\n")
append(String.format(Locale("ru"), "Рейтинг: %.0f%%", it * 100))
}
document // views
.selectFirst(".manga__views")
?.text()
?.replace(" ", "")
?.toIntOrNull()
?.also {
if (isNotEmpty()) append("\n\n")
append(String.format(Locale("ru"), "Просмотров: %,d", it))
}
document // favorites
.selectFirst(".manga")
?.attr("data-fav-count")
?.takeIf { it.isNotEmpty() }
?.toIntOrNull()
?.also {
if (isNotEmpty()) append("\n\n")
append(String.format(Locale("ru"), "Избранное: %,d", it))
}
document // alternative names
.select(".manga__name-alt > span, .manga-mobile__name-alt > span")
.eachText()
.takeIf { it.isNotEmpty() }
?.also {
if (isNotEmpty()) append("\n\n")
append("Альтернативные названия:\n")
append(it.joinToString("\n") { "$it" })
}
}
genre = buildList {
addAll(document.select(".manga__middle-links > a:not(:last-child)").eachText())
addAll(document.select(".manga-mobile__info > a:not(:last-child)").eachText())
addAll(document.select(".tags > .tags__item").eachText())
}.takeIf { it.isNotEmpty() }?.joinToString()
status = document
.select(".manga__middle-links > a:last-child, .manga-mobile__info > a:last-child")
.text()
.parseStatus()
thumbnail_url = document
.selectFirst(".manga__img img, img.manga-mobile__image")
?.absUrl("src")
}
// Chapters
override fun chapterListSelector() = "a.chapters__item"
override fun chapterFromElement(element: Element) = SChapter.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
name = element.select(".chapters__volume, .chapters__value, .chapters__name").text()
date_upload = runCatching {
dateFormat.parse(element.selectFirst(".chapters__add-date")!!.text())!!.time
}.getOrDefault(0L)
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string())
val chapters = super.chapterListParse(response)
// HTML only shows 100 entries. If this class is present it will load more via API
if (document.selectFirst(".load-chapters-trigger") == null) {
return chapters
}
val mangaId = document.selectFirst(".manga")?.attr("data-id")
?: throw Exception("Не удалось найти ID манги")
val form = FormBody.Builder()
.add("manga_id", mangaId)
.build()
val moreChapters = client
.newCall(POST("$baseUrl/chapters/load", headers, form))
.execute()
.parseAs<WrappedHtmlDto>()
.content
.let(Jsoup::parseBodyFragment)
.select(chapterListSelector())
.map(::chapterFromElement)
return chapters + moreChapters
}
// Pages
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
override fun pageListParse(document: Document): List<Page> {
return document.select(".reader__pages img").mapIndexed { i, img ->
Page(i, document.location(), img.imgAttr())
}
}
// Other
override fun getFilterList() = FilterList(
Filter.Header("ПРИМЕЧАНИЕ: Игнорируется, если используется поиск по тексту!"),
Filter.Separator(),
SortFilter(),
GenreFilter(),
TypeFilter(),
TagFilter(),
StatusFilter(),
AgeFilter(),
RatingFilter(),
YearFilter(),
ChapterCountFilter(),
)
private fun String.parseStatus(): Int = when (this.lowercase()) {
"завершен" -> SManga.COMPLETED
"продолжается" -> SManga.ONGOING
"заморожен" -> SManga.ON_HIATUS
"заброшен" -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
private fun Element.imgAttr(): String = when {
hasAttr("data-src") -> absUrl("data-src")
else -> absUrl("src")
}
private inline fun <reified T> Response.parseAs(): T =
json.decodeFromString(body.string())
@Serializable
class WrappedHtmlDto(
val content: String,
)
companion object {
const val SEARCH_PREFIX = "slug:"
private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.ROOT)
}
}

View File

@ -0,0 +1,289 @@
package eu.kanade.tachiyomi.extension.ru.mangabuff
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
defaultValue: String? = null,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
) {
val selected get() = options[state].second.takeUnless { it.isEmpty() }
}
class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name)
abstract class CheckBoxGroup(
name: String,
options: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }.takeUnless { it.isEmpty() }
}
class TriStateFilter(name: String, val value: String) : Filter.TriState(name)
abstract class TriStateGroup(
name: String,
private val options: List<Pair<String, String>>,
) : Filter.Group<TriStateFilter>(
name,
options.map { TriStateFilter(it.first, it.second) },
) {
val included get() = state.filter { it.isIncluded() }.map { it.value }.takeUnless { it.isEmpty() }
val excluded get() = state.filter { it.isExcluded() }.map { it.value }.takeUnless { it.isEmpty() }
}
class SortFilter(defaultOrder: String? = null) : SelectFilter("Сортировать по", sort, defaultOrder) {
companion object {
private val sort = listOf(
Pair("Популярные", "views"),
Pair("Обновленные", "updated_at"),
Pair("По рейтингу", "rating"),
Pair("По новинкам", "created_at"),
)
val POPULAR = FilterList(SortFilter("views"))
val LATEST = FilterList(SortFilter("updated_at"))
}
}
class GenreFilter : TriStateGroup("Жанр", genres) {
companion object {
private val genres = listOf(
Pair("Арт", "1"),
Pair("Боевик", "2"),
Pair("Боевые искусства", "4"),
Pair("Вампиры", "5"),
Pair("Гарем", "6"),
Pair("Гендерная интрига", "7"),
Pair("Героическое фэнтези", "8"),
Pair("Детектив", "9"),
Pair("Дзёсэй", "10"),
Pair("Додзинси", "11"),
Pair("Драма", "12"),
Pair("Ёнкома", "39"),
Pair("Игра", "18"),
Pair("История", "13"),
Pair("Киберпанк", "21"),
Pair("Кодомо", "40"),
Pair("Комедия", "14"),
Pair("Махо-сёдзе", "20"),
Pair("Меха", "15"),
Pair("Мистика", "16"),
Pair("Научная фантастика", "17"),
Pair("Повседневность", "19"),
Pair("Постапокалиптика", "22"),
Pair("Приключения", "24"),
Pair("Психология", "25"),
Pair("Романтика", "26"),
Pair("Самурайский боевик", "28"),
Pair("Сверхъестественное", "30"),
Pair("Сёдзё", "31"),
Pair("Сёнэн", "29"),
Pair("Спорт", "32"),
Pair("Сэйнэн", "33"),
Pair("Трагедия", "23"),
Pair("Триллер", "34"),
Pair("Ужасы", "35"),
Pair("Фантастика", "27"),
Pair("Фэнтези", "36"),
Pair("Школа", "3"),
Pair("Эротика", "37"),
Pair("Этти", "38"),
)
}
}
class TypeFilter : TriStateGroup("Тип", types) {
companion object {
private val types = listOf(
Pair("Манга", "1"),
Pair("OEL-манга", "2"),
Pair("Манхва", "3"),
Pair("Маньхуа", "4"),
Pair("Сингл", "5"),
Pair("Руманга", "6"),
Pair("Комикс западный", "7"),
)
}
}
class TagFilter : TriStateGroup("теги", tags) {
companion object {
private val tags = listOf(
Pair("Азартные игры", "7759"),
Pair("Алхимия", "7750"),
Pair("Амнезия / Потеря памяти", "7776"),
Pair("амнезия/потеря памяти", "7780"),
Pair("Ангелы", "7744"),
Pair("Антигерой", "7691"),
Pair("Антиутопия", "7755"),
Pair("Апокалипсис", "7774"),
Pair("Армия", "7767"),
Pair("Артефакты", "7727"),
Pair("Боги", "7679"),
Pair("Бои на мечах", "7700"),
Pair("Борьба за власть", "7734"),
Pair("Брат и сестра", "7725"),
Pair("Будущее", "7756"),
Pair("в первый раз", "7695"),
Pair("Ведьма", "7772"),
Pair("Вестерн", "7771"),
Pair("Видеоигры", "7704"),
Pair("Виртуальная реальность", "7760"),
Pair("Владыка демонов", "7743"),
Pair("Военные", "7676"),
Pair("Война", "7770"),
Pair("Волшебники / маги", "7680"),
Pair("Волшебные существа", "7721"),
Pair("Воспоминания из другого мира", "7713"),
Pair("Выживание", "7739"),
Pair("ГГ женщина", "7702"),
Pair("ГГ имба", "7709"),
Pair("ГГ мужчина", "7681"),
Pair("Геймеры", "7758"),
Pair("Гильдии", "7762"),
Pair("Глупый ГГ", "7718"),
Pair("Гоблины", "7766"),
Pair("Горничные", "7753"),
Pair("Гяру", "7773"),
Pair("Демоны", "7682"),
Pair("Драконы", "7751"),
Pair("Дружба", "7703"),
Pair("Жестокий мир", "7728"),
Pair("Жестокость", "7784"),
Pair("Животные компаньоны", "7752"),
Pair("Завоевание мира", "7748"),
Pair("Зверолюди", "7707"),
Pair("Злые духи", "7683"),
Pair("Зомби", "7726"),
Pair("Игровые элементы", "7723"),
Pair("Империи", "7711"),
Pair("Квесты", "7735"),
Pair("Космос", "7749"),
Pair("Кулинария", "7740"),
Pair("Культивация", "7731"),
Pair("Легендарное оружие", "7714"),
Pair("Лоли", "7791"),
Pair("Магическая академия", "7684"),
Pair("Магия", "7677"),
Pair("Мафия", "7690"),
Pair("Медицина", "7761"),
Pair("Месть", "7741"),
Pair("Монстр Девушки", "7719"),
Pair("Монстродевушки", "7720"),
Pair("Монстры", "7685"),
Pair("Музыка", "7675"),
Pair("Навыки / способности", "7715"),
Pair("Наёмники", "7764"),
Pair("Насилие / жестокость", "7692"),
Pair("Нежить", "7686"),
Pair("Ниндзя", "7732"),
Pair("Обмен телами", "7757"),
Pair("Обратный Гарем", "7705"),
Pair("Огнестрельное оружие", "7777"),
Pair("Офисные Работники", "7754"),
Pair("Пародия", "7745"),
Pair("Пираты", "7724"),
Pair("Подземелья", "7722"),
Pair("Политика", "7736"),
Pair("Полиция", "7693"),
Pair("Преступники / Криминал", "7733"),
Pair("Призраки / Духи", "7687"),
Pair("Путешествие во времени", "7710"),
Pair("Путешествия во времени", "7730"),
Pair("Рабы", "7765"),
Pair("Разумные расы", "7688"),
Pair("Ранги силы", "7746"),
Pair("Реинкарнация", "7706"),
Pair("Роботы", "7769"),
Pair("Рыцари", "7701"),
Pair("Самураи", "7698"),
Pair("Система", "7737"),
Pair("Скрытие личности", "7708"),
Pair("Спасение мира", "7747"),
Pair("Спортивное тело", "7742"),
Pair("Средневековье", "7699"),
Pair("Стимпанк", "7781"),
Pair("Супергерои", "7775"),
Pair("Традиционные игры", "7768"),
Pair("Умный ГГ", "7716"),
Pair("Учитель / ученик", "7717"),
Pair("Философия", "7729"),
Pair("Хикикомори", "7763"),
Pair("Холодное оружие", "7738"),
Pair("Шантаж", "7778"),
Pair("Эльфы", "7678"),
Pair("юные", "7696"),
Pair("Якудза", "7689"),
Pair("Яндере", "7779"),
Pair("Япония", "7674"),
)
}
}
class StatusFilter : CheckBoxGroup("Статус", statuses) {
companion object {
private val statuses = listOf(
Pair("Завершен", "1"),
Pair("Продолжается", "2"),
Pair("Заморожен", "3"),
Pair("Заброшен", "4"),
)
}
}
class AgeFilter : CheckBoxGroup("Возрастной рейтинг", ages) {
companion object {
private val ages = listOf(
Pair("18+", "18+"),
Pair("16+", "16+"),
)
}
}
class RatingFilter : CheckBoxGroup("Рейтинг", ratings) {
companion object {
private val ratings = listOf(
Pair("Рейтинг 50%+", "5"),
Pair("Рейтинг 60%+", "6"),
Pair("Рейтинг 70%+", "7"),
Pair("Рейтинг 80%+", "8"),
Pair("Рейтинг 90%+", "9"),
)
}
}
class YearFilter : CheckBoxGroup("Год выпуска", years) {
companion object {
private val years = listOf(
Pair("2024", "2024"),
Pair("2023", "2023"),
Pair("2022", "2022"),
Pair("2021", "2021"),
Pair("2020", "2020"),
Pair("2019", "2019"),
Pair("2018", "2018"),
Pair("2017", "2017"),
Pair("2016", "2016"),
)
}
}
class ChapterCountFilter : CheckBoxGroup("Колличество глав", chapters) {
companion object {
private val chapters = listOf(
Pair("<50", "0"),
Pair("50-100", "50"),
Pair("100-200", "100"),
Pair(">200", "200"),
)
}
}

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.extension.ru.mangabuff
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class MangaBuffUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val slug = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${MangaBuff.SEARCH_PREFIX}$slug")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MangaBuffUrlActivity", e.toString())
}
} else {
Log.e("MangaBuffUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}