diff --git a/src/en/hentairead/build.gradle b/src/en/hentairead/build.gradle index 4a786702c..71c5464a2 100644 --- a/src/en/hentairead/build.gradle +++ b/src/en/hentairead/build.gradle @@ -3,7 +3,7 @@ ext { extClass = '.Hentairead' themePkg = 'madara' baseUrl = 'https://hentairead.com' - overrideVersionCode = 6 + overrideVersionCode = 7 isNsfw = true } diff --git a/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadDTO.kt b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadDTO.kt new file mode 100644 index 000000000..e87a6f576 --- /dev/null +++ b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadDTO.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.extension.en.hentairead + +import kotlinx.serialization.Serializable + +@Serializable +class Results( + val results: List, +) + +@Serializable +class Result( + val id: Int, + val text: String, +) diff --git a/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadFilters.kt b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadFilters.kt new file mode 100644 index 000000000..548473af6 --- /dev/null +++ b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/HentaiReadFilters.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.extension.en.hentairead + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.Filter.Sort.Selection +import eu.kanade.tachiyomi.source.model.FilterList + +fun getFilters(): FilterList { + return FilterList( + SortFilter("Sort by", Selection(0, false), getSortsList), + TypeFilter("Types"), + Filter.Separator(), + Filter.Header("Separate tags with commas (,)"), + Filter.Header("Prepend with dash (-) to exclude [ Only for 'Tags' ]"), + TextFilter("Tags", "manga_tag"), + Filter.Separator(), + TextFilter("Artists", "artist"), + TextFilter("Circles", "circle"), + TextFilter("Characters", "character"), + TextFilter("Collections", "collection"), + TextFilter("Scanlators", "scanlator"), + TextFilter("Conventions", "convention"), + Filter.Separator(), + Filter.Header("Filter by year uploaded, for example: (>2024)"), + UploadedFilter("Uploaded"), + Filter.Separator(), + Filter.Header("Filter by pages, for example: (>20)"), + PageFilter("Pages"), + ) +} + +internal open class UploadedFilter(name: String) : Filter.Text(name) + +internal open class PageFilter(name: String) : Filter.Text(name) + +internal open class TextFilter(name: String, val type: String) : Filter.Text(name) + +internal class TypeFilter(name: String) : + Filter.Group( + name, + listOf( + "Doujinshi" to "4", + "Manga" to "52", + "Artist CG" to "4798", + ).map { CheckBoxFilter(it.first, it.second, true) }, + ) +internal open class CheckBoxFilter(name: String, val value: String, state: Boolean) : Filter.CheckBox(name, state) + +internal open class SortFilter(name: String, selection: Selection, private val vals: List>) : + Filter.Sort(name, vals.map { it.first }.toTypedArray(), selection) { + fun getValue() = vals[state!!.index].second +} + +private val getSortsList: List> = listOf( + Pair("Latest", "new"), + Pair("A-Z", "alphabet"), + Pair("Rating", "rating"), + Pair("Views", "views"), +) diff --git a/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/Hentairead.kt b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/Hentairead.kt index c20d6cad1..a432df3ae 100644 --- a/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/Hentairead.kt +++ b/src/en/hentairead/src/eu/kanade/tachiyomi/extension/en/hentairead/Hentairead.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.en.hentairead import eu.kanade.tachiyomi.multisrc.madara.Madara import eu.kanade.tachiyomi.network.GET 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 @@ -11,6 +12,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request +import okhttp3.Response import org.jsoup.nodes.Document import org.jsoup.nodes.Element import rx.Observable @@ -37,9 +39,48 @@ class Hentairead : Madara("HentaiRead", "https://hentairead.com", "en", dateForm .build() override val mangaSubString = "hentai" - override val fetchGenres = false + override fun popularMangaNextPageSelector(): String? = "a[rel=next]" + override fun mangaDetailsParse(document: Document): SManga { + fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s -> + s.replaceFirstChar { sr -> + if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString() + } + } + return SManga.create().apply { + val authors = document.select("a[href*=/circle/] span:first-of-type").eachText().joinToString() + val artists = document.select("a[href*=/artist/] span:first-of-type").eachText().joinToString() + initialized = true + author = authors.ifEmpty { artists } + artist = artists.ifEmpty { authors } + genre = document.select("a[href*=/tag/] span:first-of-type").eachText().joinToString() - override fun getFilterList() = FilterList() + description = buildString { + document.select("a[href*=/characters/] span:first-of-type").eachText().joinToString().ifEmpty { null }?.let { + append("Characters: ", it.capitalizeEach(), "\n\n") + } + document.select("a[href*=/parody/] span:first-of-type").eachText().joinToString().ifEmpty { null }?.let { + append("Parodies: ", it.capitalizeEach(), "\n\n") + } + document.select("a[href*=/circle/] span:first-of-type").eachText().joinToString().ifEmpty { null }?.let { + append("Circles: ", it.capitalizeEach(), "\n\n") + } + document.select("a[href*=/convention/] span:first-of-type").eachText().joinToString().ifEmpty { null }?.let { + append("Convention: ", it.capitalizeEach(), "\n\n") + } + document.select("a[href*=/scanlator/] span:first-of-type").eachText().joinToString().ifEmpty { null }?.let { + append("Scanlators: ", it.capitalizeEach(), "\n\n") + } + document.selectFirst(".manga-titles h2")?.text()?.ifEmpty { null }?.let { + val titles = it.split("|").joinToString("\n") { "- ${it.trim()}" } + append("Alternative Titles: ", "\n", titles, "\n\n") + } + append(document.select(".items-center:contains(pages:)").text(), "\n") + } + status = SManga.COMPLETED + update_strategy = UpdateStrategy.ONLY_FETCH_ONCE + } + } + override fun getFilterList(): FilterList = getFilters() override fun searchLoadMoreRequest(page: Int, query: String, filters: FilterList): Request { val url = "$baseUrl${searchPage(page)}".toHttpUrl().newBuilder() @@ -50,20 +91,97 @@ class Hentairead : Madara("HentaiRead", "https://hentairead.com", "en", dateForm return GET(url, headers) } - override fun searchMangaSelector() = "div.c-tabs-item div.page-item-detail" + override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/$mangaSubString/${searchPage(page)}?sortby=views", headers) + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/$mangaSubString/${searchPage(page)}?sortby=new", headers) + override fun popularMangaSelector() = ".manga-item" + override val popularMangaUrlSelector = ".manga-item__bottom a" - override val mangaDetailsSelectorDescription = "div.post-sub-title.alt-title > h2" - override val mangaDetailsSelectorAuthor = "div.post-meta.post-tax-wp-manga-artist > span.post-tags > a > span.tag-name" - override val mangaDetailsSelectorArtist = "div.post-meta.post-tax-wp-manga-artist > span.post-tags > a > span.tag-name" - override val mangaDetailsSelectorGenre = "div.post-meta.post-tax-wp-manga-genre > span.post-tags > a > span.tag-name" - override val mangaDetailsSelectorTag = "div.post-meta.post-tax-wp-manga-tag > span.post-tags > a > span.tag-name" + private fun getTagId(tag: String, type: String): Int? { + val ajax = "$baseUrl/wp-admin/admin-ajax.php?action=search_manga_terms&search=$tag&taxonomy=$type".replace("artist", "manga_artist") + val res = client.newCall(GET(ajax, headers)).execute() + val items = res.parseAs() + val item = items.results.filter { it.text.lowercase() == tag.lowercase() } + if (item.isNotEmpty()) { + return item[0].id + } + return null + } + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments("page/$page") + addQueryParameter("s", query) + addQueryParameter("title-type", "contains") + filters.forEach { + when (it) { + is TypeFilter -> { + val (activeFilter, inactiveFilters) = it.state.partition { stIt -> stIt.state } + activeFilter.map { fil -> addQueryParameter("categories[]", fil.value) } + } - override val pageListParseSelector = "li.chapter-image-item > a > div.image-wrapper" + is PageFilter -> { + if (it.state.isNotBlank()) { + val (min, max) = parsePageRange(it.state) + addQueryParameter("pages", "$min-$max") + } + } - override fun mangaDetailsParse(document: Document): SManga { - return super.mangaDetailsParse(document).apply { - update_strategy = UpdateStrategy.ONLY_FETCH_ONCE - status = SManga.COMPLETED + is UploadedFilter -> { + if (it.state.isNotBlank()) { + val type = when (it.state.firstOrNull()) { + '>' -> "after" + '<' -> "before" + else -> "in" + } + addQueryParameter("release-type", type) + addQueryParameter("release", it.state.filter(Char::isDigit)) + } + } + + is TextFilter -> { + if (it.state.isNotEmpty()) { + it.state.split(",").filter(String::isNotBlank).map { tag -> + val trimmed = tag.trim() + val id = getTagId(trimmed.removePrefix("-"), it.type)?.toString() + ?: throw Exception("${it.type.lowercase().replaceFirstChar(Char::uppercase)} not found: ${trimmed.removePrefix("-")}") + if (it.type == "manga_tag") { + if (trimmed.startsWith('-')) { + addQueryParameter("excluding[]", id) + } else { + addQueryParameter("including[]", id) + } + } else { + addQueryParameter("${it.type}s[]", id) + } + } + } + } + + is SortFilter -> { + addQueryParameter("sortby", it.getValue()) + addQueryParameter("order", if (it.state!!.ascending) "asc" else "desc") + } + else -> {} + } + } + }.build() + return GET(url, headers) + } + + private fun parsePageRange(query: String, minPages: Int = 1, maxPages: Int = 9999): Pair { + val num = query.filter(Char::isDigit).toIntOrNull() ?: -1 + fun limitedNum(number: Int = num): Int = number.coerceIn(minPages, maxPages) + + if (num < 0) return minPages to maxPages + return when (query.firstOrNull()) { + '<' -> 1 to if (query[1] == '=') limitedNum() else limitedNum(num + 1) + '>' -> limitedNum(if (query[1] == '=') num else num + 1) to maxPages + '=' -> when (query[1]) { + '>' -> limitedNum() to maxPages + '<' -> 1 to limitedNum(maxPages) + else -> limitedNum() to limitedNum() + } + else -> limitedNum() to limitedNum() } } @@ -71,10 +189,10 @@ class Hentairead : Madara("HentaiRead", "https://hentairead.com", "en", dateForm override fun pageListParse(document: Document): List { launchIO { countViews(document) } - val pages = document.selectFirst("#chapter_preloaded_images")?.data() - ?.substringAfter("chapter_preloaded_images = ") + val pages = document.selectFirst("[id=single-chapter-js-extra]")?.data() + ?.substringAfter(":[") ?.substringBefore("],") - ?.let { json.decodeFromString>("$it]") } + ?.let { json.decodeFromString>("[$it]") } ?: throw Exception("Failed to find page list. Non-English entries are not supported.") return pages.mapIndexed { idx, page -> @@ -88,6 +206,9 @@ class Hentairead : Madara("HentaiRead", "https://hentairead.com", "en", dateForm SChapter.create().apply { name = "Chapter" url = manga.url + if (manga.description?.contains("Scanlators") == true) { + scanlator = manga.description?.substringAfter("Scanlators: ")?.substringBefore("\n") + } }, ), ) @@ -112,6 +233,10 @@ class Hentairead : Madara("HentaiRead", "https://hentairead.com", "en", dateForm else -> element.attr("abs:src") } } + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } } @Serializable