diff --git a/src/pt/sakuramangas/build.gradle b/src/pt/sakuramangas/build.gradle new file mode 100644 index 000000000..ca190b001 --- /dev/null +++ b/src/pt/sakuramangas/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Sakura Mangás' + extClass = '.SakuraMangas' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/sakuramangas/res/mipmap-hdpi/ic_launcher.png b/src/pt/sakuramangas/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d689f154a Binary files /dev/null and b/src/pt/sakuramangas/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/sakuramangas/res/mipmap-mdpi/ic_launcher.png b/src/pt/sakuramangas/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e51a91422 Binary files /dev/null and b/src/pt/sakuramangas/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/sakuramangas/res/mipmap-xhdpi/ic_launcher.png b/src/pt/sakuramangas/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..058ff47b9 Binary files /dev/null and b/src/pt/sakuramangas/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/sakuramangas/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/sakuramangas/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..922a0488a Binary files /dev/null and b/src/pt/sakuramangas/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/sakuramangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/sakuramangas/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..bb0e14b35 Binary files /dev/null and b/src/pt/sakuramangas/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt new file mode 100644 index 000000000..a1f1522be --- /dev/null +++ b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt @@ -0,0 +1,322 @@ +package eu.kanade.tachiyomi.extension.pt.sakuramangas + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.parseAs +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.util.Calendar +import kotlin.concurrent.thread + +class SakuraMangas : HttpSource() { + override val lang = "pt-BR" + + override val supportsLatest = true + + override val name = "Sakura Mangás" + + override val baseUrl = "https://sakuramangas.org" + + private var genresSet: Set = emptySet() + private var demographyOptions: List> = listOf( + "Todos" to "", + ) + private var classificationOptions: List> = listOf( + "Todos" to "", + ) + private var orderByOptions: List> = listOf( + "Lidos" to "3", + ) + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + .set("X-Requested-With", "XMLHttpRequest") + + // ================================ Popular ======================================= + + override fun popularMangaRequest(page: Int): Request = + searchMangaRequest(page, "", FilterList()) + + override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response) + + // ================================ Latest ======================================= + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/dist/sakura/models/home/home_ultimos.php", headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val result = response.parseAs>() + + val mangas = result.map { + val element = Jsoup.parseBodyFragment(it, baseUrl) + SManga.create().apply { + title = element.selectFirst(".h5-titulo")!!.text() + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + thumbnail_url = element.selectFirst("img")?.absUrl("src") + } + } + + return MangasPage(mangas, hasNextPage = false) + } + + // ================================ Search ======================================= + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val form = FormBody.Builder() + .add("seach", query) + .add("order", "3") + .add("offset", ((page - 1) * 15).toString()) + .add("limit", "15") + + val inclGenres = mutableListOf() + val exclGenres = mutableListOf() + + var demography: String? = null + var classification: String? = null + var orderBy: String? = null + + filters.forEach { filter -> + when (filter) { + is GenreList -> filter.state.forEach { + when (it.state) { + Filter.TriState.STATE_INCLUDE -> inclGenres.add(it.id) + Filter.TriState.STATE_EXCLUDE -> exclGenres.add(it.id) + else -> {} + } + } + + is DemographyFilter -> demography = filter.getValue().ifEmpty { null } + is ClassificationFilter -> classification = filter.getValue().ifEmpty { null } + is OrderByFilter -> orderBy = filter.getValue().ifEmpty { null } + else -> {} + } + } + + inclGenres.forEach { form.add("tags[]", it) } + exclGenres.forEach { form.add("excludeTags[]", it) } + + demography?.let { form.add("demography", it) } + classification?.let { form.add("classification", it) } + orderBy?.let { form.add("order", it) } + + return POST("$baseUrl/dist/sakura/models/obras/obras_buscar.php", headers, form.build()) + } + + fun searchMangaFromElement(element: Element) = SManga.create().apply { + title = element.selectFirst(".h5-titulo")!!.text() + thumbnail_url = element.selectFirst("img.img-pesquisa")?.absUrl("src") + description = element.selectFirst(".p-sinopse")?.text() + + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + } + + override fun searchMangaParse(response: Response): MangasPage { + val result = response.parseAs() + val seriesList = + result.asJsoup("$baseUrl/obras/").select(".result-item").map(::searchMangaFromElement) + return MangasPage(seriesList, result.hasMore) + } + + // ================================ Details ======================================= + + override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}" + + private fun mangaDetailsApiRequest(mangaId: String): Request { + val form = FormBody.Builder() + .add("manga_id", mangaId) + .add("dataType", "json") + + return POST("$baseUrl/dist/sakura/models/manga/manga_info.php", headers, form.build()) + } + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") + + return client.newCall(mangaDetailsApiRequest(mangaId)).execute() + .parseAs().toSManga(document.baseUri()) + } + + // ================================ Chapters ======================================= + + private fun chapterListApiRequest(mangaId: String, page: Int): Request { + val form = FormBody.Builder() + .add("manga_id", mangaId) + .add("offset", ((page - 1) * 90).toString()) + .add("order", "desc") + .add("limit", "90") + + return POST("$baseUrl/dist/sakura/models/manga/manga_capitulos.php", headers, form.build()) + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") + + var page = 1 + val chapters = mutableListOf() + do { + val doc = client.newCall(chapterListApiRequest(mangaId, page++)).execute().asJsoup() + + val chapterGroup = doc.select(".capitulo-item").map(::chapterFromElement).also { + chapters += it + } + } while (chapterGroup.isNotEmpty()) + + return chapters + } + + fun chapterFromElement(element: Element) = SChapter.create().apply { + name = buildString { + element.selectFirst(".num-capitulo") + ?.text() + ?.let { append(it) } + + element.selectFirst(".cap-titulo") + ?.text() + ?.takeIf { it.isNotBlank() } + ?.let { append(" - $it") } + } + scanlator = element.selectFirst(".scan-nome")?.text() + chapter_number = + element + .selectFirst(".num-capitulo")!! + .attr("data-chapter") + .toFloatOrNull() ?: 1F + date_upload = element.selectFirst(".cap-data")?.text()?.toDate() ?: 0L + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + } + + // ================================ Pages ======================================= + + private fun pageListApiRequest(chapterId: String, token: String): Request { + val form = FormBody.Builder() + .add("chapter_id", chapterId) + .add("token", token) + + return POST( + "$baseUrl/dist/sakura/models/capitulo/capitulos_read.php", + headers, + form.build(), + ) + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + + val chapterId = document.selectFirst("meta[chapter-id]")!!.attr("chapter-id") + val token = document.selectFirst("meta[token]")!!.attr("token") + + val response = client.newCall(pageListApiRequest(chapterId, token)).execute() + .parseAs() + + val baseUrl = document.baseUri().trimEnd('/') + + return response.imageUrls.mapIndexed { index, url -> + Page( + index, + imageUrl = "$baseUrl/$url".toHttpUrl().toString(), + ) + } + } + + override fun imageUrlParse(response: Response): String = "" + + override fun getFilterList(): FilterList { + thread { + fetchFilters() + } + + return FilterList( + OrderByFilter("Ordenar por", orderByOptions, "order"), + DemographyFilter("Demografia", demographyOptions, "demography"), + ClassificationFilter("Classificação", classificationOptions, "classification"), + GenreList( + title = "Gêneros", + genres = genresSet.toTypedArray(), + ), + ) + } + + private fun fetchFilters() { + if (genresSet.isNotEmpty()) { + return + } + + try { + val document = client + .newCall(GET("$baseUrl/obras/", headers)) + .execute() + .asJsoup() + + genresSet = document.select(".genero-badge").map { element -> + val id = element.attr("data-value") + Genre(element.ownText(), id) + }.toSet() + + val demoOpts = document.select("select#demografia-select option").mapNotNull { opt -> + val value = opt.attr("value").orEmpty() + val text = opt.text().trim() + if (text.isEmpty()) null else text to value + } + if (demoOpts.isNotEmpty()) demographyOptions = demoOpts + + val classOpts = + document.select("select#classificacao-select option").mapNotNull { opt -> + val value = opt.attr("value").orEmpty() + val text = opt.text().trim() + if (text.isEmpty()) null else text to value + } + if (classOpts.isNotEmpty()) classificationOptions = classOpts + + val orderOptions = document.select("select#ordenar-por option").mapNotNull { opt -> + val value = opt.attr("value").orEmpty() + val text = opt.text().trim() + if (text.isEmpty()) null else text to value + } + if (orderOptions.isNotEmpty()) orderByOptions = orderOptions + } catch (e: Exception) { + Log.e("SakuraMangas", "failed to fetch genres", e) + } + } + + private fun String.toDate(): Long { + val trimmedDate = this.split(" ") + + if (trimmedDate[0] != "Há") return 0L + + val number = trimmedDate[1].toIntOrNull() ?: return 0L + + val unit = trimmedDate[2] + + val javaUnit = when (unit) { + "ano", "anos" -> Calendar.YEAR + "mês", "meses" -> Calendar.MONTH + "semana", "semanas" -> Calendar.WEEK_OF_MONTH + "dia", "dias" -> Calendar.DAY_OF_MONTH + "hora", "horas" -> Calendar.HOUR + "minuto", "minutos" -> Calendar.MINUTE + "segundo", "segundos" -> Calendar.SECOND + else -> return 0L + } + + val now = Calendar.getInstance() + + now.add(javaUnit, -number) + + return now.timeInMillis + } +} diff --git a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt new file mode 100644 index 000000000..898e0b178 --- /dev/null +++ b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt @@ -0,0 +1,60 @@ +package eu.kanade.tachiyomi.extension.pt.sakuramangas + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +@Serializable +class SakuraMangasResultDto( + val hasMore: Boolean, + private val html: String, +) { + + fun asJsoup(baseUri: String = ""): Document { + return Jsoup.parseBodyFragment(this.html, baseUri) + } +} + +@Serializable +class SakuraMangaInfoDto( + private val titulo: String, + private val autor: String?, + private val sinopse: String?, + private val tags: List, + private val demografia: String?, + private val status: String, + private val ano: Int?, + private val classificacao: String?, + private val avaliacao: Double?, +) { + fun toSManga(mangaUrl: String): SManga = SManga.create().apply { + title = titulo + author = autor + genre = tags.joinToString() + status = when (this@SakuraMangaInfoDto.status) { + "concluído" -> SManga.COMPLETED + "em andamento" -> SManga.ONGOING + else -> SManga.UNKNOWN + } + description = buildString { + sinopse?.takeIf { it.isNotBlank() }?.let { + appendLine(it) + appendLine() + } + ano?.let { appendLine("Ano: $it") } + demografia?.takeIf { it.isNotBlank() }?.let { appendLine("Demografia: $it") } + classificacao?.takeIf { it.isNotBlank() }?.let { appendLine("Classificação: $it") } + avaliacao?.let { appendLine("Avaliação: $it") } + }.trim() + thumbnail_url = "${mangaUrl.trimEnd('/')}/thumb_256.jpg" + url = mangaUrl.toHttpUrl().encodedPath + initialized = true + } +} + +@Serializable +class SakuraMangaChapterReadDto( + val imageUrls: List, +) diff --git a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasFilter.kt b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasFilter.kt new file mode 100644 index 000000000..1786146ab --- /dev/null +++ b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasFilter.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.extension.pt.sakuramangas + +import eu.kanade.tachiyomi.source.model.Filter + +class GenreList(title: String, genres: Array) : + Filter.Group(title, genres.map { GenreCheckBox(it.name, it.id) }) + +class GenreCheckBox(name: String, val id: String = name) : Filter.TriState(name) + +class Genre(val name: String, val id: String) + +open class SingleSelectFilter( + name: String, + private val options: List>, + val paramKey: String, + state: Int = 0, +) : Filter.Select(name, options.map { it.first }.toTypedArray(), state) { + fun getValue(): String = options.getOrNull(state)?.second.orEmpty() +} + +class DemographyFilter( + name: String, + options: List>, + paramKey: String, +) : SingleSelectFilter(name, options, paramKey) + +class ClassificationFilter( + name: String, + options: List>, + paramKey: String, +) : SingleSelectFilter(name, options, paramKey) + +class OrderByFilter( + name: String, + options: List>, + paramKey: String, + state: Int = 0, +) : SingleSelectFilter(name, options, paramKey, state)