diff --git a/src/pt/mangaterra/AndroidManifest.xml b/src/pt/mangaterra/AndroidManifest.xml new file mode 100644 index 000000000..0b740e3f6 --- /dev/null +++ b/src/pt/mangaterra/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/pt/mangaterra/build.gradle b/src/pt/mangaterra/build.gradle new file mode 100644 index 000000000..aefaab357 --- /dev/null +++ b/src/pt/mangaterra/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Manga Terra' + extClass = '.MangaTerra' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d1db501d7 Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..573ba1bf7 Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..36570c4a5 Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..958f433bf Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..82a9daaaf Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png differ 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 new file mode 100644 index 000000000..762232fbb --- /dev/null +++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerra.kt @@ -0,0 +1,242 @@ +package eu.kanade.tachiyomi.extension.pt.mangaterra + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +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) + .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(slugPrefix)) { + val slug = query.substringAfter(slugPrefix) + 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")) + val slugPrefix = "slug:" + } +} diff --git a/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt new file mode 100644 index 000000000..2f823e692 --- /dev/null +++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.extension.pt.mangaterra + +import eu.kanade.tachiyomi.source.model.Filter + +class Genre(name: String, val query: String, val value: String) : Filter.CheckBox(name) + +class GenreFilter(title: String, genres: List) : Filter.Group(title, genres) diff --git a/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt new file mode 100644 index 000000000..3e14eded1 --- /dev/null +++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.extension.pt.mangaterra + +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 MangaTerraUrlActivity : Activity() { + + private val tag = javaClass.simpleName + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", slug(pathSegments)) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e(tag, e.toString()) + } + } else { + Log.e(tag, "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } + + private fun slug(pathSegments: List) = "${MangaTerra.slugPrefix}${pathSegments.last()}" +}