Add MyComic (#8986)

* Add MyComic

* MyComic: Replace Object filter to class filter

* Apply suggestions from code review

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Apply review suggestions.

* Try to use selectFirst as much as possible.

* Apply review suggestions

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
This commit is contained in:
AlphaBoom 2025-05-31 08:18:39 +08:00 committed by Draff
parent af50939f4e
commit 3a0f6ddddf
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 366 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'MyComic'
extClass = '.MyComic'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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<Pair<String, String>>,
state: Int = 0,
) :
Filter.Select<String>(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",
),
)

View File

@ -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<SChapter> {
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<List<Chapter>>().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<Page> {
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<SortFilter>()
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<UriPartFilter>().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"
}
}

View File

@ -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)