diff --git a/src/zh/mycomic/build.gradle b/src/zh/mycomic/build.gradle new file mode 100644 index 000000000..d408b3b2a --- /dev/null +++ b/src/zh/mycomic/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'MyComic' + extClass = '.MyComic' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/mycomic/res/mipmap-hdpi/ic_launcher.png b/src/zh/mycomic/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ab404e5e5 Binary files /dev/null and b/src/zh/mycomic/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/mycomic/res/mipmap-mdpi/ic_launcher.png b/src/zh/mycomic/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..cebf60998 Binary files /dev/null and b/src/zh/mycomic/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/mycomic/res/mipmap-xhdpi/ic_launcher.png b/src/zh/mycomic/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..7fc930249 Binary files /dev/null and b/src/zh/mycomic/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/mycomic/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/mycomic/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..94950ae01 Binary files /dev/null and b/src/zh/mycomic/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/mycomic/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/mycomic/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..79ec4f4a8 Binary files /dev/null and b/src/zh/mycomic/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/Filters.kt b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/Filters.kt new file mode 100644 index 000000000..b4ca78d76 --- /dev/null +++ b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/Filters.kt @@ -0,0 +1,144 @@ +package eu.kanade.tachiyomi.extension.zh.mycomic + +import eu.kanade.tachiyomi.source.model.Filter + +open class UriPartFilter( + val key: String, + name: String, + private val pairs: List>, + state: Int = 0, +) : + Filter.Select(name, pairs.map { it.first }.toTypedArray(), state) { + val selected + get() = pairs[state].second +} + +class SortFilter(state: Int) : UriPartFilter( + "sort", + "排序", + listOf( + "最新上架" to "", + "最近更新" to "-update", + "最高人气" to "-views", + "日排行" to RANK_PREFIX, + "週排行" to "$RANK_PREFIX-week", + "月排行" to "$RANK_PREFIX-month", + "歷史排行" to "$RANK_PREFIX-views", + ), + state, +) { + companion object { + const val RANK_PREFIX = "rank|" + } +} + +class RegionFilter : UriPartFilter( + "filter[country]", + "作品地区", + listOf( + "所有" to "", + "日本" to "japan", + "港台" to "hongkong", + "歐美" to "europe", + "內地" to "china", + "韓國" to "korea", + "其他" to "other", + ), +) + +class TagFilter : UriPartFilter( + "filter[tag]", + "作品类型", + listOf( + "所有" to "", + "魔幻" to "mohuan", + "魔法" to "mofa", + "熱血" to "rexue", + "冒險" to "maoxian", + "懸疑" to "xuanyi", + "偵探" to "zhentan", + "愛情" to "aiqing", + "校園" to "xiaoyuan", + "搞笑" to "gaoxiao", + "四格" to "sige", + "科幻" to "kehuan", + "神鬼" to "shengui", + "舞蹈" to "wudao", + "音樂" to "yinyue", + "百合" to "baihe", + "後宮" to "hougong", + "機戰" to "jizhan", + "格鬥" to "gedou", + "恐怖" to "kongbu", + "萌系" to "mengxi", + "武俠" to "wuxia", + "社會" to "shehui", + "歷史" to "lishi", + "耽美" to "danmei", + "勵志" to "lizhi", + "職場" to "zhichang", + "生活" to "shenghuo", + "治癒" to "zhiyu", + "偽娘" to "weiniang", + "黑道" to "heidao", + "戰爭" to "zhanzheng", + "競技" to "jingji", + "體育" to "tiyu", + "美食" to "meishi", + "腐女" to "funv", + "宅男" to "zhainan", + "推理" to "tuili", + "雜誌" to "zazhi", + ), +) + +class AudienceFilter : UriPartFilter( + "filter[audience]", + "适合受众", + listOf( + "所有" to "", + "少女" to "shaonv", + "少年" to "shaonian", + "青年" to "qingnian", + "兒童" to "ertong", + "通用" to "tongyong", + ), +) + +class YearFilter : UriPartFilter( + "filter[year]", + "出品年份", + listOf( + "所有" to "", + "2025" to "2025", + "2024" to "2024", + "2023" to "2023", + "2022" to "2022", + "2021" to "2021", + "2020" to "2020", + "2019" to "2019", + "2018" to "2018", + "2017" to "2017", + "2016" to "2016", + "2015" to "2015", + "2014" to "2014", + "2013" to "2013", + "2012" to "2012", + "2011" to "2011", + "2010" to "2010", + "00年代" to "200x", + "90年代" to "199x", + "80年代" to "198x", + "70年代或更早" to "197x", + ), +) + +class StatusFilter : UriPartFilter( + "filter[end]", + "目前进度", + listOf( + "所有" to "", + "連載中" to "0", + "已完結" to "1", + ), +) diff --git a/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComic.kt b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComic.kt new file mode 100644 index 000000000..7e1d1313d --- /dev/null +++ b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComic.kt @@ -0,0 +1,206 @@ +package eu.kanade.tachiyomi.extension.zh.mycomic + +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.ConfigurableSource +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 keiyoushi.utils.firstInstance +import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.parseAs +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +class MyComic : ParsedHttpSource(), ConfigurableSource { + override val baseUrl = "https://mycomic.com" + override val lang: String = "zh" + override val name: String = "MyComic" + override val supportsLatest: Boolean = true + + override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/") + private val preferences by getPreferencesLazy() + private val requestUrl: String + get() = if (preferences.getString(PREF_KEY_LANG, "") == "zh-hans") { + "$baseUrl/cn" + } else { + baseUrl + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val data = document.select("div[data-flux-card] + div div[x-data]").attr("x-data") + val chaptersStr = + data.substringAfter("chapters:").substringBefore("\n").trim().removeSuffix(",") + return chaptersStr.parseAs>().map { + SChapter.create().apply { + name = it.title + // Since the images included in the chapter do not distinguish between Traditional and Simplified Chinese, the default URL will be used uniformly here. + // Additionally, using different URLs would create more issues, so it's best to keep the URL consistent. + url = "/chapters/${it.id}" + } + } + } + + override fun chapterFromElement(element: Element) = throw UnsupportedOperationException() + + override fun chapterListSelector() = throw UnsupportedOperationException() + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + + override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element) + + override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector() + + override fun latestUpdatesRequest(page: Int) = + searchMangaRequest(page, "", FilterList(latestUpdateFilter)) + + override fun latestUpdatesSelector() = searchMangaSelector() + + override fun mangaDetailsParse(document: Document): SManga { + val detailElement = document.selectFirst("div[data-flux-card]")!! + return SManga.create().apply { + title = detailElement.selectFirst("div[data-flux-heading]")!!.text() + thumbnail_url = detailElement.selectFirst("img.object-cover")?.imgAttr() + status = detailElement.selectFirst("div[data-flux-badge]")?.text().let { + when (it) { + "连载中", "連載中" -> SManga.ONGOING + "已完结", "已完結" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + detailElement.selectFirst("div[data-flux-badge] + div")?.let { element -> + author = element.selectFirst(":first-child a")?.text() + genre = element.select(":nth-child(3) a").joinToString { it.text() } + } + description = + detailElement.selectFirst("div[data-flux-badge] + div + div div[x-show=show]") + ?.text() ?: document.selectFirst("meta[name=description]")?.attr("content") + } + } + + override fun pageListParse(document: Document): List { + return document.select("img[x-ref]").mapIndexed { index, element -> + Page(index, imageUrl = element.imgAttr()) + } + } + + override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element) + + override fun popularMangaNextPageSelector() = searchMangaNextPageSelector() + + override fun popularMangaRequest(page: Int) = + searchMangaRequest(page, "", FilterList(popularFilter)) + + override fun popularMangaSelector() = searchMangaSelector() + + override fun searchMangaSelector() = "div.grid > div.group" + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.encodedPath == "/rank") { + val doc = response.asJsoup() + return MangasPage( + doc.select("table > tbody > tr > td:nth-child(2) a").map { + SManga.create().apply { + setUrlWithoutDomain(it.absUrl("href")) + title = it.text() + // ranking page not support thumbnail + } + }, + false, + ) + } else { + return super.searchMangaParse(response) + } + } + + override fun searchMangaFromElement(element: Element): SManga { + return SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + element.selectFirst("img")!!.let { + title = it.attr("alt") + thumbnail_url = it.imgAttr() + } + } + } + + override fun searchMangaNextPageSelector() = "nav[role=navigation] a[rel=next]" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val sortFilter = filters.firstInstance() + val isRankFilter = sortFilter.selected.startsWith(SortFilter.RANK_PREFIX) + val url = if (isRankFilter) { + "$requestUrl/rank" + } else { + "$requestUrl/comics" + }.toHttpUrl().newBuilder() + if (!isRankFilter) { + url.addQueryParameterIfNotEmpty("q", query) + } + url.addQueryParameterIfNotEmpty( + sortFilter.key, + sortFilter.selected.removePrefix(SortFilter.RANK_PREFIX), + ) + filters.list.filterIsInstance().forEach { + if (it is SortFilter) { + return@forEach + } + url.addQueryParameterIfNotEmpty(it.key, it.selected) + } + if (!isRankFilter && page > 1) { + url.addQueryParameter("page", page.toString()) + } + return GET(url.build(), headers = headers) + } + + override fun getFilterList(): FilterList { + return FilterList( + SortFilter(0), + RegionFilter(), + TagFilter(), + AudienceFilter(), + YearFilter(), + StatusFilter(), + ) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + screen.addPreference( + ListPreference(screen.context).apply { + key = PREF_KEY_LANG + title = "設置首選語言" + summary = "當前:%s" + entries = arrayOf("繁體中文", "简体中文") + entryValues = arrayOf("zh-hant", "zh-hans") + setDefaultValue(entryValues[0]) + }, + ) + } + + private fun HttpUrl.Builder.addQueryParameterIfNotEmpty(name: String, value: String) { + if (value.isNotEmpty()) { + addQueryParameter(name, value) + } + } + + private fun Element.imgAttr() = when { + hasAttr("data-src") -> absUrl("data-src") + else -> absUrl("src") + } + + companion object { + val popularFilter = SortFilter(2) + val latestUpdateFilter = SortFilter(1) + + const val PREF_KEY_LANG = "pref_key_lang" + } +} diff --git a/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComicDTO.kt b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComicDTO.kt new file mode 100644 index 000000000..94eb6102a --- /dev/null +++ b/src/zh/mycomic/src/eu/kanade/tachiyomi/extension/zh/mycomic/MyComicDTO.kt @@ -0,0 +1,8 @@ +// ktlint-disable filename + +package eu.kanade.tachiyomi.extension.zh.mycomic + +import kotlinx.serialization.Serializable + +@Serializable +class Chapter(val id: Long, val title: String)