diff --git a/src/en/todaymanga/build.gradle b/src/en/todaymanga/build.gradle new file mode 100644 index 000000000..6db476c99 --- /dev/null +++ b/src/en/todaymanga/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'TodayManga' + extClass = '.TodayManga' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/todaymanga/res/mipmap-hdpi/ic_launcher.png b/src/en/todaymanga/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e7ab46262 Binary files /dev/null and b/src/en/todaymanga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/todaymanga/res/mipmap-mdpi/ic_launcher.png b/src/en/todaymanga/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4e9a8d4a5 Binary files /dev/null and b/src/en/todaymanga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/todaymanga/res/mipmap-xhdpi/ic_launcher.png b/src/en/todaymanga/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7953a1551 Binary files /dev/null and b/src/en/todaymanga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/todaymanga/res/mipmap-xxhdpi/ic_launcher.png b/src/en/todaymanga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..882512c04 Binary files /dev/null and b/src/en/todaymanga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/todaymanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/todaymanga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..5d136a886 Binary files /dev/null and b/src/en/todaymanga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/todaymanga/src/eu/kanade/tachiyomi/extension/en/todaymanga/TodayManga.kt b/src/en/todaymanga/src/eu/kanade/tachiyomi/extension/en/todaymanga/TodayManga.kt new file mode 100644 index 000000000..745e59c9c --- /dev/null +++ b/src/en/todaymanga/src/eu/kanade/tachiyomi/extension/en/todaymanga/TodayManga.kt @@ -0,0 +1,336 @@ +package eu.kanade.tachiyomi.extension.en.todaymanga + +import eu.kanade.tachiyomi.network.GET +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 okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +open class TodayManga : ParsedHttpSource() { + + override val name = "TodayManga" + + override val baseUrl = "https://todaymanga.com" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request = + GET("$baseUrl/category/most-popular".addPage(page), headers) + + override fun popularMangaSelector(): String = "section div.serie" + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a[href]")!!.attr("abs:href")) + thumbnail_url = element.selectFirst("img")!!.imgAttr() + title = element.selectFirst("h2")!!.text() + } + + override fun popularMangaNextPageSelector(): String = + ".pagination > ul > li.active + li:has(a)" + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/category/recent".addPage(page), headers) + + override fun latestUpdatesSelector(): String = "ul.series > li" + + override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { + with(element.selectFirst("a[title][href]")!!) { + setUrlWithoutDomain(attr("abs:href")) + title = attr("title") + } + thumbnail_url = element.selectFirst("img")!!.imgAttr() + } + + override fun latestUpdatesNextPageSelector(): String = + popularMangaNextPageSelector() + + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val filterList = if (filters.isEmpty()) getFilterList() else filters + val categoryFilter = filterList.filterIsInstance().first() + val genreFilter = filterList.filterIsInstance().first() + + val url = baseUrl.toHttpUrl().newBuilder().apply { + when { + categoryFilter.state != 0 -> { + addPathSegment("category") + addPathSegments(categoryFilter.toUriPart()) + } + genreFilter.state != 0 -> { + addPathSegment("genre") + addPathSegment(genreFilter.toUriPart()) + } + query.isNotBlank() -> { + addPathSegment("search") + addQueryParameter("q", query) + } + else -> { // Default to popular + addPathSegments("category/most-popular") + } + } + + if (page > 1) { + addQueryParameter("page", page.toString()) + } + }.build() + + return GET(url, headers) + } + + override fun searchMangaSelector(): String = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga = + popularMangaFromElement(element) + + override fun searchMangaNextPageSelector(): String = + popularMangaNextPageSelector() + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangaList = document.select(searchMangaSelector()) + .map(::searchMangaFromElement) + .ifEmpty { + document.select(latestUpdatesSelector()) + .map(::latestUpdatesFromElement) + } + + val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null + return MangasPage(mangaList, hasNextPage) + } + + // =============================== Filters ============================== + + override fun getFilterList(): FilterList = FilterList( + Filter.Header("Ignored when using text search"), + Filter.Header("NOTE: Only one filter will be applied!"), + Filter.Separator(), + CategoryFilter(), + GenreFilter(), + ) + + class CategoryFilter : UriPartFilter( + "Category", + arrayOf( + Pair("", ""), + Pair("Action", "action"), + Pair("Adventure", "adventure"), + Pair("Comedy", "comedy"), + Pair("Drama", "drama"), + Pair("Ecchi", "ecchi"), + Pair("Fantasy", "fantasy"), + Pair("Gender Bender", "gender-bender"), + Pair("Harem", "harem"), + Pair("Historical", "historical"), + Pair("Horror", "horror"), + Pair("Martial Arts", "martial-arts"), + Pair("Mature", "mature"), + Pair("Music", "music"), + Pair("Mystery", "mystery"), + Pair("One Shot", "one-shot"), + Pair("Psychological", "psychological"), + Pair("Reverse Harem", "reverse-harem"), + Pair("Romance", "romance"), + Pair("School Life", "school-life"), + Pair("Sci fi", "sci-fi"), + Pair("Seinen", "seinen"), + Pair("Shoujo", "shoujo"), + Pair("Shounen Ai", "shounen-ai"), + Pair("Shounen", "shounen"), + Pair("Slice Of Life", "slice-of-life"), + Pair("Sports", "sports"), + Pair("Supernatural", "supernatural"), + Pair("Tragedy", "tragedy"), + Pair("Vampire", "vampire"), + Pair("Webtoons", "webtoons"), + ), + ) + + open class UriPartFilter(displayName: String, private val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + // =========================== Manga Details ============================ + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + with(document.selectFirst(".serie")!!) { + title = selectFirst("h1")!!.text() + thumbnail_url = selectFirst("img")!!.imgAttr() + genre = select(".serie-info-head .tags > .tag-item").joinToString { it.text() } + author = select(".authors a").joinToString { it.text() } + status = selectFirst("li:contains(status) span").parseStatus() + } + + description = buildString { + val summary = document.selectFirst(".serie-summary")!! + summary.childNodes().forEach { node -> + if (node is TextNode) { + append(node.text()) + } + if (node.nodeName() == "br") { + appendLine() + } + } + summary.selectFirst("div[style]")?.also { + append("\n\n") + append(it.text()) + } + }.trim() + } + + private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) { + "complete" -> SManga.COMPLETED + "on going" -> SManga.ONGOING + else -> SManga.UNKNOWN + } + + // ============================== Chapters ============================== + + override fun chapterListRequest(manga: SManga): Request { + val chapterHeaders = headersBuilder().apply { + add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + set("Referer", baseUrl + manga.url) + }.build() + return GET("$baseUrl${manga.url}/chapter-list", chapterHeaders) + } + + override fun chapterListSelector() = "ul.chapters-list > li" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + with(element.selectFirst("a")!!) { + name = text() + setUrlWithoutDomain(attr("abs:href")) + } + + val dateText = element.selectFirst(".subtitle")?.text() + date_upload = if (dateText == null) { + 0L + } else if (dateText.contains("ago")) { + dateText.parseRelativeDate() + } else { + parseDate(dateText) + } + } + + private fun parseDate(dateStr: String): Long { + return try { + dateFormat.parse(dateStr)!!.time + } catch (_: ParseException) { + 0L + } + } + + // From OppaiStream + private fun String.parseRelativeDate(): Long { + val now = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + val relativeDate = this.split(" ").firstOrNull() + ?.replace("one", "1") + ?.replace("a", "1") + ?.toIntOrNull() + ?: return 0L + + when { + "second" in this -> now.add(Calendar.SECOND, -relativeDate) // parse: 30 seconds ago + "minute" in this -> now.add(Calendar.MINUTE, -relativeDate) // parses: "42 minutes ago" + "hour" in this -> now.add(Calendar.HOUR, -relativeDate) // parses: "1 hour ago" and "2 hours ago" + "day" in this -> now.add(Calendar.DAY_OF_YEAR, -relativeDate) // parses: "2 days ago" + "week" in this -> now.add(Calendar.WEEK_OF_YEAR, -relativeDate) // parses: "2 weeks ago" + "month" in this -> now.add(Calendar.MONTH, -relativeDate) // parses: "2 months ago" + "year" in this -> now.add(Calendar.YEAR, -relativeDate) // parse: "2 years ago" + } + return now.timeInMillis + } + + // =============================== Pages ================================ + + override fun pageListParse(document: Document): List { + return document.select(".chapter-content > img[data-index]").map { img -> + val index = img.attr("data-index").toInt() + val url = img.imgAttr() + Page(index, imageUrl = url) + }.sortedBy { it.index } + } + + override fun imageUrlParse(document: Document) = "" + + override fun imageRequest(page: Page): Request { + val imgHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + return GET(page.imageUrl!!, imgHeaders) + } + + // ============================= Utilities ============================== + + private fun String.addPage(page: Int): HttpUrl { + return this.toHttpUrl().newBuilder().apply { + if (page > 1) { + addQueryParameter("page", page.toString()) + } + }.build() + } + + private fun Element.imgAttr(): String = when { + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("data-src") -> attr("abs:data-src") + else -> attr("abs:src") + } +}