diff --git a/src/pt/mangaterra/AndroidManifest.xml b/lib-multisrc/terrascan/AndroidManifest.xml similarity index 72% rename from src/pt/mangaterra/AndroidManifest.xml rename to lib-multisrc/terrascan/AndroidManifest.xml index 0b740e3f6..795883568 100644 --- a/src/pt/mangaterra/AndroidManifest.xml +++ b/lib-multisrc/terrascan/AndroidManifest.xml @@ -2,7 +2,7 @@ @@ -13,9 +13,9 @@ + android:host="${SOURCEHOST}" + android:pathPattern="/manga/..*" + android:scheme="${SOURCESCHEME}" /> diff --git a/lib-multisrc/terrascan/build.gradle.kts b/lib-multisrc/terrascan/build.gradle.kts new file mode 100644 index 000000000..dc076cc37 --- /dev/null +++ b/lib-multisrc/terrascan/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 diff --git a/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScan.kt b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScan.kt new file mode 100644 index 000000000..c286c4f87 --- /dev/null +++ b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScan.kt @@ -0,0 +1,245 @@ +package eu.kanade.tachiyomi.multisrc.terrascan + +import eu.kanade.tachiyomi.network.GET +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 eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +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 + +abstract class TerraScan( + override val name: String, + override val baseUrl: String, + override val lang: String, + private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd-MM-yyyy", Locale("pt", "BR")), +) : ParsedHttpSource() { + + override val supportsLatest: Boolean = true + + override val client = network.cloudflareClient + + private val noRedirectClient = network.cloudflareClient.newBuilder() + .followRedirects(false) + .build() + + private val json: Json by injectLazy() + + private var genresList: List = emptyList() + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?q=p&page=$page", headers) + + open val popularMangaTitleSelector: String = "p, h3" + open val popularMangaThumbnailSelector: String = "img" + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + title = element.selectFirst(popularMangaTitleSelector)!!.ownText() + thumbnail_url = element.selectFirst(popularMangaThumbnailSelector)?.srcAttr() + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + } + + override fun popularMangaNextPageSelector() = ".pagination > .page-item:not(.disabled):last-child" + + override fun popularMangaSelector(): String = ".series-paginated .grid-item-series, .series-paginated .series" + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + if (genresList.isEmpty()) { + genresList = parseGenres(document) + } + val mangas = document.select(popularMangaSelector()) + .map(::popularMangaFromElement) + + return MangasPage(mangas, document.selectFirst(popularMangaNextPageSelector()) != null) + } + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga?q=u&page=$page", headers) + + override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + override fun latestUpdatesSelector() = popularMangaSelector() + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(URL_SEARCH_PREFIX)) { + val slug = query.substringAfter(URL_SEARCH_PREFIX) + return client.newCall(GET("$baseUrl/manga/$slug", headers)) + .asObservableSuccess().map { response -> + MangasPage(listOf(mangaDetailsParse(response)), false) + } + } + return super.fetchSearchManga(page, query, filters) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder() + + if (query.isNotBlank()) { + url.addPathSegment("search") + .addQueryParameter("q", query) + return GET(url.build(), headers) + } + + url.addPathSegment("manga") + + filters.forEach { filter -> + when (filter) { + is GenreFilter -> { + filter.state.forEach { + if (it.state) { + url.addQueryParameter(it.query, it.value) + } + } + } + else -> {} + } + } + + url.addQueryParameter("page", "$page") + + return GET(url.build(), headers) + } + + override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector() = null + + override fun searchMangaSelector() = ".col-6.col-sm-3.col-md-3.col-lg-2.p-1" + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.pathSegments.contains("search")) { + return searchByQueryMangaParse(response) + } + return super.searchMangaParse(response) + } + + override fun getFilterList(): FilterList { + val filters = mutableListOf>() + if (genresList.isNotEmpty()) { + filters += GenreFilter( + title = "Gêneros", + genres = genresList, + ) + } else { + filters += Filter.Header("Aperte 'Redefinir' mostrar os gêneros disponíveis") + } + return FilterList(filters) + } + + open val mangaDetailsContainerSelector: String = "main" + open val mangaDetailsTitleSelector: String = "h1" + open val mangaDetailsThumbnailSelector: String = "img" + open val mangaDetailsDescriptionSelector: String = "p" + open val mangaDetailsGenreSelector: String = ".card:has(h5:contains(Categorias)) a, .card:has(h5:contains(Categorias)) div" + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + with(document.selectFirst(mangaDetailsContainerSelector)!!) { + title = selectFirst(mangaDetailsTitleSelector)!!.text() + thumbnail_url = selectFirst(mangaDetailsThumbnailSelector)?.absUrl("href") + description = selectFirst(mangaDetailsDescriptionSelector)?.text() + genre = document.select(mangaDetailsGenreSelector) + .joinToString { it.ownText() } + } + setUrlWithoutDomain(document.location()) + } + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + with(element.selectFirst("h5")!!) { + name = ownText() + date_upload = selectFirst("div")!!.ownText().toDate() + } + setUrlWithoutDomain(element.absUrl("href")) + } + + override fun chapterListSelector() = ".col-chapter a" + + override fun pageListParse(document: Document): List { + val mangaChapterUrl = document.location() + val maxPage = findPageCount(mangaChapterUrl) + return (1..maxPage).map { page -> Page(page - 1, "$mangaChapterUrl/$page") } + } + + override fun imageUrlParse(document: Document) = document.selectFirst("main img")!!.srcAttr() + + private fun searchByQueryMangaParse(response: Response): MangasPage { + val fragment = Jsoup.parseBodyFragment( + json.decodeFromString(response.body.string()), + baseUrl, + ) + + return MangasPage( + mangas = fragment.select(searchMangaSelector()).map(::searchMangaFromElement), + hasNextPage = false, + ) + } + + private fun findPageCount(pageUrl: String): Int { + var lowerBound = 1 + var upperBound = 100 + + while (lowerBound <= upperBound) { + val midpoint = lowerBound + (upperBound - lowerBound) / 2 + + val request = Request.Builder().apply { + url("$pageUrl/$midpoint") + headers(headers) + head() + }.build() + + val response = try { + noRedirectClient.newCall(request).execute() + } catch (e: Exception) { + throw Exception("Failed to fetch $pageUrl") + } + + if (response.code == 302) { + upperBound = midpoint - 1 + } else { + lowerBound = midpoint + 1 + } + } + + return lowerBound + } + + private fun Element.srcAttr(): String = when { + hasAttr("data-src") -> absUrl("data-src") + else -> absUrl("src") + } + + private fun String.toDate() = try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L } + + open val genreFilterSelector: String = "form div > div:has(input) div" + + private fun parseGenres(document: Document): List { + return document.select(genreFilterSelector) + .map { element -> + val input = element.selectFirst("input")!! + Genre( + name = element.selectFirst("label")!!.ownText(), + query = input.attr("name"), + value = input.attr("value"), + ) + } + } + + companion object { + const val URL_SEARCH_PREFIX = "slug:" + } +} diff --git a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraFilters.kt b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanFilters.kt similarity index 81% rename from src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraFilters.kt rename to lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanFilters.kt index 2f823e692..169d23490 100644 --- a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraFilters.kt +++ b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanFilters.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.pt.mangaterra +package eu.kanade.tachiyomi.multisrc.terrascan import eu.kanade.tachiyomi.source.model.Filter diff --git a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraUrlActivity.kt b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanUrlActivity.kt similarity index 85% rename from src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraUrlActivity.kt rename to lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanUrlActivity.kt index d4fdc09e1..12824b8fb 100644 --- a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerraUrlActivity.kt +++ b/lib-multisrc/terrascan/src/eu/kanade/tachiyomi/multsrc/terrascan/TerraScanUrlActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.pt.mangaterra +package eu.kanade.tachiyomi.multisrc.terrascan import android.app.Activity import android.content.ActivityNotFoundException @@ -7,7 +7,7 @@ import android.os.Bundle import android.util.Log import kotlin.system.exitProcess -class MangaTerraUrlActivity : Activity() { +class TerraScanUrlActivity : Activity() { private val tag = javaClass.simpleName @@ -35,5 +35,5 @@ class MangaTerraUrlActivity : Activity() { } private fun slug(pathSegments: List) = - "${MangaTerra.PREFIX_SEARCH}${pathSegments[pathSegments.size - 1]}" + "${TerraScan.URL_SEARCH_PREFIX}${pathSegments[pathSegments.size - 1]}" } diff --git a/src/pt/mangabr/build.gradle b/src/pt/mangabr/build.gradle new file mode 100644 index 000000000..839d7f368 --- /dev/null +++ b/src/pt/mangabr/build.gradle @@ -0,0 +1,11 @@ +ext { + extName = 'Manga BR' + extClass = '.MangaBR' + themePkg = 'terrascan' + baseUrl = 'https://mangabr.net' + overrideVersionCode = 0 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + diff --git a/src/pt/mangabr/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangabr/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..9313f9d9a Binary files /dev/null and b/src/pt/mangabr/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/mangabr/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangabr/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..33879db0f Binary files /dev/null and b/src/pt/mangabr/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/mangabr/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangabr/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..150a75da8 Binary files /dev/null and b/src/pt/mangabr/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/mangabr/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangabr/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..992837354 Binary files /dev/null and b/src/pt/mangabr/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/mangabr/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangabr/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..991cfd8d3 Binary files /dev/null and b/src/pt/mangabr/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/mangabr/src/eu/kanade/tachiyomi/extension/pt/mangabr/MangaBR.kt b/src/pt/mangabr/src/eu/kanade/tachiyomi/extension/pt/mangabr/MangaBR.kt new file mode 100644 index 000000000..65ff3b3fb --- /dev/null +++ b/src/pt/mangabr/src/eu/kanade/tachiyomi/extension/pt/mangabr/MangaBR.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.pt.mangabr + +import eu.kanade.tachiyomi.multisrc.terrascan.TerraScan +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import java.util.concurrent.TimeUnit + +class MangaBR : TerraScan( + "Manga BR", + "https://mangabr.net", + "pt-BR", +) { + override val client = super.client.newBuilder() + .rateLimit(1, 2, TimeUnit.SECONDS) + .build() +} diff --git a/src/pt/mangaterra/build.gradle b/src/pt/mangaterra/build.gradle index cc78a1d50..9de425fea 100644 --- a/src/pt/mangaterra/build.gradle +++ b/src/pt/mangaterra/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'Manga Terra' extClass = '.MangaTerra' - extVersionCode = 2 + themePkg = 'terrascan' + baseUrl = 'https://manga-terra.com' + overrideVersionCode = 2 isNsfw = true } diff --git a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerra.kt b/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerra.kt index 085d09332..fd8c5938f 100644 --- a/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerra.kt +++ b/src/pt/mangaterra/src/eu/kanade/tachiyomi/extension/pt/mangaterra/MangaTerra.kt @@ -1,242 +1,15 @@ package eu.kanade.tachiyomi.extension.pt.mangaterra -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.multisrc.terrascan.TerraScan import eu.kanade.tachiyomi.network.interceptor.rateLimit -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 eu.kanade.tachiyomi.util.asJsoup -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import okhttp3.HttpUrl.Companion.toHttpUrl -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 import java.util.concurrent.TimeUnit -class MangaTerra : ParsedHttpSource() { - override val lang: String = "pt-BR" - override val supportsLatest: Boolean = true - override val name: String = "Manga Terra" - override val baseUrl: String = "https://manga-terra.com" - - override val client = network.cloudflareClient.newBuilder() - .rateLimit(5, 2, TimeUnit.SECONDS) +class MangaTerra : TerraScan( + "Manga Terra", + "https://manga-terra.com", + "pt-BR", +) { + override val client = super.client.newBuilder() + .rateLimit(1, 2, TimeUnit.SECONDS) .build() - - private val noRedirectClient = network.cloudflareClient.newBuilder() - .followRedirects(false) - .build() - - private val json: Json by injectLazy() - - private var fetchGenresAttempts: Int = 0 - - private var genresList: List = emptyList() - - override fun chapterFromElement(element: Element) = SChapter.create().apply { - name = element.selectFirst("h5")!!.ownText() - date_upload = element.selectFirst("h5 > div")!!.ownText().toDate() - setUrlWithoutDomain(element.absUrl("href")) - } - - override fun chapterListSelector() = ".card-list-chapter a" - - override fun imageUrlParse(document: Document) = document.selectFirst("img")!!.srcAttr() - - override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) - - override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() - - override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga?q=u&page=$page", headers) - - override fun latestUpdatesSelector() = popularMangaSelector() - - override fun mangaDetailsParse(document: Document) = SManga.create().apply { - title = document.selectFirst(".card-body h1")!!.ownText() - description = document.selectFirst(".card-body p")?.ownText() - thumbnail_url = document.selectFirst(".card-body img")?.srcAttr() - genre = document.select(".card-series-about a").joinToString { it.ownText() } - setUrlWithoutDomain(document.location()) - } - - override fun pageListParse(document: Document): List { - val mangaChapterUrl = document.location() - val maxPage = findPageCount(mangaChapterUrl) - return (1..maxPage).map { page -> Page(page - 1, "$mangaChapterUrl/$page") } - } - - override fun popularMangaFromElement(element: Element) = SManga.create().apply { - title = element.selectFirst("p")!!.ownText() - thumbnail_url = element.selectFirst("img")?.srcAttr() - setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) - } - - override fun popularMangaNextPageSelector() = ".pagination > .page-item:not(.disabled):last-child" - - override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?q=p&page=$page", headers) - - override fun popularMangaSelector(): String = ".card-body .row > div" - - override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) - - override fun searchMangaParse(response: Response): MangasPage { - if (response.request.url.pathSegments.contains("search")) { - return searchByQueryMangaParse(response) - } - return super.searchMangaParse(response) - } - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - if (query.startsWith(PREFIX_SEARCH)) { - val slug = query.substringAfter(PREFIX_SEARCH) - return client.newCall(GET("$baseUrl/manga/$slug", headers)) - .asObservableSuccess().map { response -> - MangasPage(listOf(mangaDetailsParse(response)), false) - } - } - return super.fetchSearchManga(page, query, filters) - } - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = baseUrl.toHttpUrl().newBuilder() - - if (query.isNotBlank()) { - url.addPathSegment("search") - .addQueryParameter("q", query) - return GET(url.build(), headers) - } - - url.addPathSegment("manga") - - filters.forEach { filter -> - when (filter) { - is GenreFilter -> { - filter.state.forEach { - if (it.state) { - url.addQueryParameter(it.query, it.value) - } - } - } - else -> {} - } - } - - url.addQueryParameter("page", "$page") - - return GET(url.build(), headers) - } - - override fun searchMangaSelector() = popularMangaSelector() - - override fun getFilterList(): FilterList { - CoroutineScope(Dispatchers.IO).launch { fetchGenres() } - val filters = mutableListOf>() - - if (genresList.isNotEmpty()) { - filters += GenreFilter( - title = "Gêneros", - genres = genresList, - ) - } else { - filters += listOf( - Filter.Separator(), - Filter.Header("Aperte 'Redefinir' mostrar os gêneros disponíveis"), - ) - } - return FilterList(filters) - } - - private fun searchByQueryMangaParse(response: Response): MangasPage { - val fragment = Jsoup.parseBodyFragment( - json.decodeFromString(response.body.string()), - baseUrl, - ) - - return MangasPage( - mangas = fragment.select("div.grid-item-series").map(::searchMangaFromElement), - hasNextPage = false, - ) - } - - private fun findPageCount(pageUrl: String): Int { - var lowerBound = 1 - var upperBound = 100 - - while (lowerBound <= upperBound) { - val midpoint = lowerBound + (upperBound - lowerBound) / 2 - - val request = Request.Builder().apply { - url("$pageUrl/$midpoint") - headers(headers) - head() - }.build() - - val response = try { - noRedirectClient.newCall(request).execute() - } catch (e: Exception) { - throw Exception("Failed to fetch $pageUrl") - } - - if (response.code == 302) { - upperBound = midpoint - 1 - } else { - lowerBound = midpoint + 1 - } - } - - return lowerBound - } - - private fun Element.srcAttr(): String = when { - hasAttr("data-src") -> absUrl("data-src") - else -> absUrl("src") - } - - private fun String.toDate() = try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L } - - private fun fetchGenres() { - if (fetchGenresAttempts < 3 && genresList.isEmpty()) { - try { - genresList = client.newCall(GET("$baseUrl/manga")).execute() - .use { parseGenres(it.asJsoup()) } - } catch (_: Exception) { - } finally { - fetchGenresAttempts++ - } - } - } - - private fun parseGenres(document: Document): List { - return document.select(".form-filters .custom-checkbox") - .map { element -> - val input = element.selectFirst("input")!! - Genre( - name = element.selectFirst("label")!!.ownText(), - query = input.attr("name"), - value = input.attr("value"), - ) - } - } - - companion object { - val dateFormat = SimpleDateFormat("dd-MM-yyyy", Locale("pt", "BR")) - const val PREFIX_SEARCH = "slug:" - } }