diff --git a/src/th/nekopost/build.gradle b/src/th/nekopost/build.gradle new file mode 100644 index 000000000..03f7793d7 --- /dev/null +++ b/src/th/nekopost/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: Nekopost' + pkgNameSuffix = 'th.nekopost' + extClass = '.Nekopost' + extVersionCode = 1 + libVersion = '1.2' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/th/nekopost/res/mipmap-hdpi/ic_launcher.png b/src/th/nekopost/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0eb1c3f64 Binary files /dev/null and b/src/th/nekopost/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/th/nekopost/res/mipmap-mdpi/ic_launcher.png b/src/th/nekopost/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..00a92c30d Binary files /dev/null and b/src/th/nekopost/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/th/nekopost/res/mipmap-xhdpi/ic_launcher.png b/src/th/nekopost/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f7e232a5c Binary files /dev/null and b/src/th/nekopost/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/th/nekopost/res/mipmap-xxhdpi/ic_launcher.png b/src/th/nekopost/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..435229f04 Binary files /dev/null and b/src/th/nekopost/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/th/nekopost/res/mipmap-xxxhdpi/ic_launcher.png b/src/th/nekopost/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..da031792c Binary files /dev/null and b/src/th/nekopost/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/th/nekopost/res/web_hi_res_512.png b/src/th/nekopost/res/web_hi_res_512.png new file mode 100644 index 000000000..f7b793e7e Binary files /dev/null and b/src/th/nekopost/res/web_hi_res_512.png differ diff --git a/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/NPUtils.kt b/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/NPUtils.kt new file mode 100644 index 000000000..30f0ab3af --- /dev/null +++ b/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/NPUtils.kt @@ -0,0 +1,68 @@ +package eu.kanade.tachiyomi.extension.th.nekopost + +import java.text.SimpleDateFormat +import java.util.* + +object NPUtils { + private val urlWithoutDomainFromFullUrlRegex: Regex = Regex("^https://www\\.nekopost\\.net/manga/(.*)$") + + + fun getMangaOrChapterAlias(url: String): String { + val (urlWithoutDomain) = urlWithoutDomainFromFullUrlRegex.find(url)!!.destructured + return urlWithoutDomain + } + + fun convertDateStringToEpoch(dateStr: String, format: String = "yyyy-MM-dd"): Long = SimpleDateFormat(format, Locale("th")).parse(dateStr).time + + fun getSearchQuery(keyword: String = "", genreList: Array<String>, statusList: Array<String>): String { + val keywordQuery = "ip_keyword=$keyword" + + val genreQuery = genreList.joinToString("&") { genre -> "ip_genre[]=${getValueOf(Genre, genre)}" } + + val statusQuery = statusList.let { + if (it.isNotEmpty()) it.map { status -> getValueOf(Status, status) } + else Status.map { status -> status.second } + }.joinToString("&") { status -> "ip_status[]=$status" } + + val typeQuery = "ip_type[]=m" + + return "$keywordQuery&$genreQuery&$statusQuery&$typeQuery" + } + + val Genre = arrayOf( + Pair("Fantasy", 1), + Pair("Action", 2), + Pair("Drama", 3), + Pair("Sport", 5), + Pair("Sci-fi", 7), + Pair("Comedy", 8), + Pair("Slice of Life", 9), + Pair("Romance", 10), + Pair("Adventure", 13), + Pair("Yaoi", 23), + Pair("Yuri", 24), + Pair("Trap", 25), + Pair("Gender Bender", 26), + Pair("Mystery", 32), + Pair("Doujinshi", 37), + Pair("Grume", 41), + Pair("Shoujo", 42), + Pair("School Life", 43), + Pair("Isekai", 44), + Pair("Shounen", 46), + Pair("Second Life", 45), + Pair("Horror", 47), + Pair("One short", 48), + Pair("Seinen", 49) + ).sortedWith(compareBy { it.first }).toTypedArray() + + val Status = arrayOf( + Pair("Ongoing", 1), + Pair("Completed", 2), + Pair("Licensed", 3) + ) + + fun <T, F, S> getValueOf(array: Array<T>, name: F): S? where T : Pair<F, S> = array.find { genre -> genre.first == name }?.second + + val monthList: Array<String> = arrayOf("JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC") +} diff --git a/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/Nekopost.kt b/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/Nekopost.kt new file mode 100644 index 000000000..b912f37c1 --- /dev/null +++ b/src/th/nekopost/src/eu/kanade/tachiyomi/extension/th/nekopost/Nekopost.kt @@ -0,0 +1,248 @@ +package eu.kanade.tachiyomi.extension.th.nekopost + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Request +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.net.URL +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashSet + +class Nekopost() : ParsedHttpSource() { + override val baseUrl: String = "https://www.nekopost.net/manga/" + + private val mangaListUrl: String = "https://www.nekopost.net/project/ajax_load_update/m/" + private val chapterContentUrl: String = "https://www.nekopost.net/reader/loadChapterContent/" + private val chapterImageUrl: String = "https://www.nekopost.net/file_server/collectManga/" + private val searchUrl: String = "https://www.nekopost.net/search/" + + private val fallbackImageUrl: String = "https://www.nekopost.net/images/no_image.jpg" + + override val lang: String = "th" + override val name: String = "Nekopost" + + override val supportsLatest: Boolean = true + + private var latestMangaList: HashSet<String> = HashSet() + private var popularMangaList: HashSet<String> = HashSet() + + override fun chapterListSelector(): String = ".bg-card.card.pb-2 tr" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + element.select("a").first().let { + setUrlWithoutDomain(NPUtils.getMangaOrChapterAlias(it.attr("href"))) + name = it.text() + } + date_upload = NPUtils.convertDateStringToEpoch(element.select("b").last().nextSibling().toString().trim()) + scanlator = element.select("a").last().text() + } + + override fun imageUrlParse(document: Document): String = ".bg-card.card .p-3.text-white img" + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(latestUpdatesSelector()).filter { element -> + val dateText = element.select(".date").text().trim() + val currentDate = Calendar.getInstance(Locale("th")) + + dateText.contains(currentDate.get(Calendar.DATE).toString()) && dateText.contains(NPUtils.monthList[currentDate.get(Calendar.MONTH)]) + }.map { element -> latestUpdatesFromElement(element) }.filter { manga -> + if (!latestMangaList.contains(manga.url)) { + latestMangaList.add(manga.url) + true + } else false + } + + val hasNextPage = mangas.isNotEmpty() + + return MangasPage(mangas, hasNextPage) + } + + override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { + setUrlWithoutDomain(NPUtils.getMangaOrChapterAlias(element.select("a").attr("href"))) + title = element.select(".info > b").text().trim() + thumbnail_url = element.select(".img img").first().attr("src").replace("preview", "cover").let { url -> + if (url === "") fallbackImageUrl + else url + } + } + + override fun latestUpdatesNextPageSelector(): String? = throw Exception("Unused") + + override fun latestUpdatesRequest(page: Int): Request { + if (page == 1) latestMangaList = HashSet() + return GET("$mangaListUrl/${page - 1}") + } + + override fun latestUpdatesSelector(): String = "a[href]" + + @ExperimentalStdlibApi + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + document.select(".bg-card.card").first().let { + title = it.select(".card-title.text-silver").text() + thumbnail_url = it.select(".bg-card.card").select(".p-3.text-white").select("img").first().attr("src").let { url -> + if (url === "") fallbackImageUrl + else url + } + + it.select("table.mt-1").select("tr").let { tr -> + author = tr[0].select("td").last().text() + artist = tr[1].select("td").last().text() + status = when (tr[3].select("td").last().text()) { + "Active" -> SManga.ONGOING + "Completed" -> SManga.COMPLETED + "Licensed" -> SManga.LICENSED + else -> SManga.UNKNOWN + } + } + + description = it.select(".bg-secondary").text().trim() + genre = it.select("td[colspan='2'][valign='top']").first().text() + .replace("Category:", "") + .split(",").joinToString(", ") { genre -> + genre.trim().split("_").joinToString(" ") { str -> + if (str.toLowerCase(Locale.getDefault()) == "of") str else str.capitalize(Locale.getDefault()) + } + } + } + } + + override fun pageListParse(document: Document): List<Page> { + return JSONArray(URL("$chapterContentUrl${NPUtils.getMangaOrChapterAlias(document.location())}").readText()).let { chapterContentJSON -> + try { + val pageListJSON = chapterContentJSON.getJSONArray(3) + val chapterDataJson = chapterContentJSON.getJSONObject(1) + + val pageList: ArrayList<Page> = ArrayList() + + for (i in 0 until pageListJSON.length()) { + pageList.add( + Page(i, "", pageListJSON.getJSONObject(i).let { pageJSON -> + "$chapterImageUrl${chapterDataJson.getString("nc_project_id")}/${pageJSON.getString("chapter_id")}/${pageJSON.getString("value_url")}" + }) + ) + } + + pageList + } catch (e: JSONException) { + val pageListNameJSON = chapterContentJSON.getString(3) + val chapterDataJson = chapterContentJSON.getJSONObject(1) + + val pageListDataJSON = JSONObject(URL("$chapterImageUrl${chapterDataJson.getString("nc_project_id")}/${chapterDataJson.getString("nc_chapter_id")}/$pageListNameJSON").readText()) + val pageListJSON = pageListDataJSON.getJSONArray("pageItem") + + val pageList: ArrayList<Page> = ArrayList() + + for (i in 0 until pageListJSON.length()) { + pageList.add( + Page(i, "", pageListJSON.getJSONObject(i).let { pageJSON -> + "$chapterImageUrl${chapterDataJson.getString("nc_project_id")}/${chapterDataJson.getString("nc_chapter_id")}/${pageJSON.getString("fileName")}" + }) + ) + } + + pageList + } + } + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> popularMangaFromElement(element) }.filter { manga -> + if (!popularMangaList.contains(manga.url)) { + popularMangaList.add(manga.url) + true + } else false + } + + val hasNextPage = mangas.isNotEmpty() + + return MangasPage(mangas, hasNextPage) + } + + override fun popularMangaFromElement(element: Element): SManga = latestUpdatesFromElement(element) + + override fun popularMangaNextPageSelector(): String? = latestUpdatesNextPageSelector() + + override fun popularMangaRequest(page: Int): Request { + if (page == 1) popularMangaList = HashSet() + return GET("$mangaListUrl/${page - 1}") + } + + override fun popularMangaSelector(): String = latestUpdatesSelector() + + override fun getFilterList(): FilterList = FilterList( + GenreFilter(), + StatusFilter() + ) + + private class GenreFilter : Filter.Group<GenreCheckbox>("Genre", NPUtils.Genre.map { genre -> GenreCheckbox(genre.first) }) + + private class GenreCheckbox(genre: String) : Filter.CheckBox(genre, false) + + private class StatusFilter : Filter.Group<StatusCheckbox>("Status", NPUtils.Status.map { status -> StatusCheckbox(status.first) }) + + private class StatusCheckbox(status: String) : Filter.CheckBox(status, false) + + override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { + element.select(".project_info").select("a").let { + title = it.text() + setUrlWithoutDomain(NPUtils.getMangaOrChapterAlias(it.attr("href"))) + } + thumbnail_url = element.select("img").attr("data-original").let { url -> + if (url === "") fallbackImageUrl + else url + } + + status = when (element.select(".status").text()) { + "On Going" -> SManga.ONGOING + "Completed" -> SManga.COMPLETED + "Licensed" -> SManga.LICENSED + else -> SManga.UNKNOWN + } + } + + override fun searchMangaNextPageSelector(): String? = null + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (page > 1) throw Error("No more page") + + var queryString = query + + val genreList: Array<String> = try { + (filters.find { filter -> filter is GenreFilter } as GenreFilter).state.filter { checkbox -> checkbox.state }.map { checkbox -> checkbox.name }.toTypedArray() + } catch (e: Exception) { + emptyArray<String>() + }.let { + when { + it.isNotEmpty() -> it + NPUtils.getValueOf(NPUtils.Genre, query) == null -> it + else ->{ + queryString = "" + arrayOf(query) + } + } + } + + val statusList: Array<String> = try { + (filters.find { filter -> filter is StatusFilter } as StatusFilter).state.filter { checkbox -> checkbox.state }.map { checkbox -> checkbox.name }.toTypedArray() + } catch (e: Exception) { + emptyArray() + } + + return GET("$searchUrl?${NPUtils.getSearchQuery(queryString, genreList, statusList)}") + } + + override fun searchMangaSelector(): String = ".list_project .item" +}