Manga-TR: Add filter support and fix the image could not be loaded error (#10055)

* Add filter support and fix the image could not be loaded error

Add dynamic Genres from manga-list.html (#genreSelect), send as repeated genre[]=<value>
Add filters: Publication (durum), Translation (ceviri), Age (yas), Content Type (icerik), Special Type (tur)
Fix WebView images by resolving Base64-encoded data-src with fallbacks

* Apply requested changes
This commit is contained in:
Hasan 2025-08-11 05:49:09 +03:00 committed by Draff
parent 45f31c3b75
commit c2317eeeed
Signed by: Draff
GPG Key ID: E8A89F3211677653
2 changed files with 227 additions and 13 deletions

View File

@ -3,7 +3,7 @@ ext {
extClass = '.MangaTR' extClass = '.MangaTR'
themePkg = 'fmreader' themePkg = 'fmreader'
baseUrl = 'https://manga-tr.com' baseUrl = 'https://manga-tr.com'
overrideVersionCode = 4 overrideVersionCode = 5
isNsfw = true isNsfw = true
} }

View File

@ -1,14 +1,18 @@
package eu.kanade.tachiyomi.extension.tr.mangatr package eu.kanade.tachiyomi.extension.tr.mangatr
import android.util.Base64
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess 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.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage 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.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstanceOrNull
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
@ -16,6 +20,8 @@ import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import java.nio.charset.StandardCharsets
import kotlin.concurrent.thread
class MangaTR : FMReader("Manga-TR", "https://manga-tr.com", "tr") { class MangaTR : FMReader("Manga-TR", "https://manga-tr.com", "tr") {
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
@ -32,24 +38,106 @@ class MangaTR : FMReader("Manga-TR", "https://manga-tr.com", "tr") {
// ============================== Popular =============================== // ============================== Popular ===============================
override fun popularMangaNextPageSelector() = "div.btn-group:not(div.btn-block) button.btn-info" override fun popularMangaNextPageSelector() = "div.btn-group:not(div.btn-block) button.btn-info"
override fun popularMangaSelector() = "div.row a[data-toggle]"
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
title = element.text()
}
// =============================== Search =============================== // =============================== Search ===============================
// TODO: genre search possible but a bit of a pain // Dynamic genre list cache
override fun getFilterList() = FilterList() private var cachedGenres: List<FMReader.Genre> = emptyList()
@Volatile private var isLoadingGenres: Boolean = false
// Filters UI: site-specific filters + dynamically fetched genres
override fun getFilterList(): FilterList {
loadGenresAsync()
val baseFilters = mutableListOf<Filter<*>>(
Filter.Header("Metin araması ile filtreler birlikte kullanılmaz"),
PublicationStatusFilter(),
TranslateStatusFilter(),
AgeRestrictionFilter(),
ContentTypeFilter(),
SpecialTypeFilter(),
)
if (cachedGenres.isNotEmpty()) {
baseFilters += FMReader.GenreList(cachedGenres)
} else {
baseFilters += Filter.Header("Türleri yüklemek için 'Sıfırla' düğmesine basın")
}
return FilterList(baseFilters)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/arama.html".toHttpUrl().newBuilder() if (query.isNotBlank()) {
.addQueryParameter("icerik", query) val url = "$baseUrl/arama.html".toHttpUrl().newBuilder()
.build() .addQueryParameter("icerik", query)
return GET(url, headers) .build()
return GET(url, headers)
}
val listEndpoint = if (page <= 1) "$baseUrl/manga-list.html" else "$baseUrl/$requestPath"
val url = listEndpoint.toHttpUrl().newBuilder()
if (page > 1) {
url.addQueryParameter("listType", "pagination")
url.addQueryParameter("page", page.toString())
}
val genreFilter = filters.firstInstanceOrNull<FMReader.GenreList>()
if (genreFilter != null && genreFilter.state.isNotEmpty()) {
val included = genreFilter.state.filter { it.isIncluded() }
if (included.isNotEmpty()) {
included.forEach { url.addQueryParameter("genre[]", it.id) }
}
}
// Diğer filtreler
val filterList = filters
filterList.firstInstanceOrNull<PublicationStatusFilter>()?.let { f ->
val value = arrayOf("", "1", "2")[f.state]
if (value.isNotEmpty()) url.addQueryParameter("durum", value)
}
filterList.firstInstanceOrNull<TranslateStatusFilter>()?.let { f ->
val value = arrayOf("", "1", "2", "3", "4")[f.state]
if (value.isNotEmpty()) url.addQueryParameter("ceviri", value)
}
filterList.firstInstanceOrNull<AgeRestrictionFilter>()?.let { f ->
val value = arrayOf("", "16", "18")[f.state]
if (value.isNotEmpty()) url.addQueryParameter("yas", value)
}
filterList.firstInstanceOrNull<ContentTypeFilter>()?.let { f ->
val value = arrayOf("", "1", "2", "3", "4")[f.state]
if (value.isNotEmpty()) url.addQueryParameter("icerik", value)
}
filterList.firstInstanceOrNull<SpecialTypeFilter>()?.let { f ->
val value = arrayOf("", "2")[f.state]
if (value.isNotEmpty()) url.addQueryParameter("tur", value)
}
return GET(url.build(), headers)
} }
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val mangas = response.use { it.asJsoup() } val path = response.request.url.encodedPath
.select("div.row a[data-toggle]") return if (path.contains("/arama.html")) {
.filterNot { it.siblingElements().text().contains("Novel") } val mangas = response.asJsoup()
.map(::searchMangaFromElement) .select("div.row a[data-toggle]")
.filterNot { it.siblingElements().text().contains("Novel") }
return MangasPage(mangas, false) .map(::searchMangaFromElement)
MangasPage(mangas, false)
} else {
super.searchMangaParse(response)
}
} }
override fun searchMangaFromElement(element: Element) = SManga.create().apply { override fun searchMangaFromElement(element: Element) = SManga.create().apply {
@ -116,4 +204,130 @@ class MangaTR : FMReader("Manga-TR", "https://manga-tr.com", "tr") {
override fun pageListRequest(chapter: SChapter) = override fun pageListRequest(chapter: SChapter) =
GET("$baseUrl/${chapter.url.substringAfter("cek/")}", headers) GET("$baseUrl/${chapter.url.substringAfter("cek/")}", headers)
override val pageListImageSelector = "div.chapter-content img.chapter-img"
// Manga-TR: image URL resolution with Base64-decoded data-src
override fun getImgAttr(element: Element?): String? {
return when {
element == null -> null
element.hasAttr("data-src") -> {
try {
val encodedUrl = element.attr("data-src")
val decodedBytes = Base64.decode(encodedUrl, Base64.DEFAULT)
String(decodedBytes, StandardCharsets.UTF_8)
} catch (e: Exception) {
element.attr("abs:src")
}
}
element.hasAttr("src") -> element.attr("abs:src")
element.hasAttr("data-original") -> element.attr("abs:data-original")
else -> null
}
}
// Simple pageListParse - relies on the selector above
override fun pageListParse(document: Document): List<Page> {
return document.select(pageListImageSelector).mapIndexed { i, img ->
Page(i, imageUrl = getImgAttr(img))
}
}
// =========================== List Parse ===========================
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
// Parse genres from the current response to avoid extra requests
if (cachedGenres.isEmpty()) {
val options = document.select("#genreSelect option")
if (options.isNotEmpty()) {
cachedGenres = options.mapNotNull { opt ->
val value = opt.attr("value").trim()
val text = opt.text().trim()
if (text.isEmpty() || value.isEmpty()) null else FMReader.Genre(text, value)
}
} else {
val container = document.selectFirst("*:matchesOwn(Tür Seçiniz)")?.parent()
?: document.selectFirst("div:has(:matchesOwn(Tür Seçiniz))")
val anchors = container?.select("a")
?: document.select("a[href*=manga-list], a[href*=genre], a[href*=tur]")
val items = anchors.map { it.text().trim() }.filter { it.length > 1 }.distinct()
if (items.isNotEmpty()) {
cachedGenres = items.map { name -> FMReader.Genre(name, name.replace(' ', '+')) }
}
}
}
val mangas = document.select(popularMangaSelector()).map { popularMangaFromElement(it) }
val hasNextPage = (document.select(popularMangaNextPageSelector()).first()?.text() ?: "").let {
if (it.contains(Regex("""\w*\s\d*\s\w*\s\d*"""))) {
it.split(" ").let { pageOf -> pageOf[1] != pageOf[3] }
} else {
it.isNotEmpty()
}
}
return MangasPage(mangas, hasNextPage)
}
private fun loadGenresAsync() {
if (cachedGenres.isNotEmpty() || isLoadingGenres) return
isLoadingGenres = true
thread(name = "mangatr-load-genres", start = true) {
try {
val doc = client.newCall(GET("$baseUrl/manga-list.html", headers)).execute().asJsoup()
val options = doc.select("#genreSelect option")
if (options.isNotEmpty()) {
cachedGenres = options.mapNotNull { opt ->
val value = opt.attr("value").trim()
val text = opt.text().trim()
if (text.isEmpty() || value.isEmpty()) null else FMReader.Genre(text, value)
}
return@thread
}
val container = doc.selectFirst("*:matchesOwn(Tür Seçiniz)")?.parent()
?: doc.selectFirst("div:has(:matchesOwn(Tür Seçiniz))")
val anchors = when {
container != null -> container.select("a")
else -> doc.select("a[href*=manga-list], a[href*=genre], a[href*=tur]")
}
val items = anchors.map { it.text().trim() }.filter { it.length > 1 }.distinct()
if (items.isNotEmpty()) {
cachedGenres = items.map { name -> FMReader.Genre(name, name.replace(' ', '+')) }
}
} catch (_: Throwable) {
} finally {
isLoadingGenres = false
}
}
}
// =========================== Filters (UI) ===========================
private class PublicationStatusFilter : Filter.Select<String>(
"Yayın Durumu",
arrayOf("Tümü", "Tamamlandı", "Devam Ediyor"),
)
private class TranslateStatusFilter : Filter.Select<String>(
"Çeviri Durumu",
arrayOf("Tümü", "Devam Ediyor", "Tamamlandı", "Bırakılmış", "Yok"),
)
private class AgeRestrictionFilter : Filter.Select<String>(
"Yaş Sınırlaması",
arrayOf("Tümü", "16+", "18+"),
)
private class ContentTypeFilter : Filter.Select<String>(
"İçerik Türü",
arrayOf("Tümü", "Manga", "Novel", "Webtoon", "Anime"),
)
private class SpecialTypeFilter : Filter.Select<String>(
"Özel Tür",
arrayOf("Tümü", "Yetişkin"),
)
} }