feat(misskon): add source misskon. (#8225)

* feat(misskon): add source misskon.

refs: #1855

* feat(misskon): Improve network handling and code structure

• Network enhancements:
  - Add headers parameter for `GET` methods
  - Implement standardized query with `HttpUrl`
  - Replace `attr()` with `asbUrl()` for safe path composition

• Structural improvements:
  - Move date regex to companion object
  - Extract SimpleDateFormat as global constant
  - Refactor Filter logic into standalone class

• Robustness upgrades:
  - Remove unsafe non-null assertion(!!) on date_upload
  - Adopt SManga.create().apply  { } chaining pattern

refs: #1855

* feat(misskon): improve code structure and null safety

- Replace `attr()` with `absUrl()` for more reliable URL extraction
- Remove non-null assertion (`!!`) from `thumbnail_url` as it's not critical
- Fix usage of `select()` and `selectFirst()` to properly handle nullable cases

refs: #1855

* chore(MissKon): remove needless blank line

- Fix lint violation (no-consecutive-blank-lines)

Closes: #1855

* refactor(MissKon): optimize URL handling and reuse utils

* Remove baseUrl from category URLs (build dynamically when used)
* Reuse existing utils for:
  - Date parsing
  - Filter queries
* Standardize pagination URL construction

Closes: #1855

* fix(MissKon): Correct URL template, set default gallery name, and unify selectors

Closes: #1855

* refactor(MissKon): fix URL construction and client configuration

- Fix string interpolation in search URL ("$it.url"  → "${it.url}")
- Remove MOBILE user agent restriction
- Remove unused dependency
- Add ONLY_FETCH_ONCE update strategy
- Clean up selector syntax
This commit is contained in:
marioplus 2025-03-31 23:07:40 +08:00 committed by Draff
parent 6341d5ff73
commit 40a9d2ec6a
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
9 changed files with 310 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'MissKon'
extClass = '.MissKon'
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.7 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,135 @@
package eu.kanade.tachiyomi.extension.all.misskon
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
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.model.UpdateStrategy
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.tryParse
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
class MissKon() : SimpleParsedHttpSource() {
override val baseUrl = "https://misskon.com"
override val lang = "all"
override val name = "MissKon"
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 10, 1, TimeUnit.SECONDS)
.build()
override fun simpleMangaSelector() = "article.item-list"
override fun simpleMangaFromElement(element: Element): SManga {
val titleEL = element.selectFirst(".post-box-title")!!
return SManga.create().apply {
title = titleEL.text()
thumbnail_url = element.selectFirst(".post-thumbnail img")?.absUrl("data-src")
setUrlWithoutDomain(titleEL.selectFirst("a")!!.absUrl("href"))
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
}
}
override fun simpleNextPageSelector(): String? = null
// region popular
override fun popularMangaRequest(page: Int) = GET("$baseUrl/top3/", headers)
// endregion
// region latest
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/$page", headers)
override fun latestUpdatesNextPageSelector() = ".current + a.page"
// endregion
// region Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val filter = filters.firstInstance<SourceCategorySelector>()
return filter.selectedCategory?.let {
GET("$baseUrl${it.url}", headers)
} ?: run {
"$baseUrl/page/$page/".toHttpUrl().newBuilder()
.addEncodedQueryParameter("s", query)
.build()
.let { GET(it, headers) }
}
}
override fun searchMangaNextPageSelector() = "div.content > div.pagination > span.current + a"
// endregion
// region Details
override fun mangaDetailsParse(document: Document): SManga {
val postInnerEl = document.selectFirst("article > .post-inner")!!
return SManga.create().apply {
title = postInnerEl.select(".post-title").text()
genre = postInnerEl.select(".post-tag > a").joinToString { it.text() }
}
}
override fun chapterListSelector() = "html"
override fun chapterFromElement(element: Element): SChapter {
val dateStr = element.selectFirst(".entry img")?.absUrl("data-src")
?.let { url ->
FULL_DATE_REGEX.find(url)?.groupValues?.get(1)
?: YEAR_MONTH_REGEX.find(url)?.groupValues?.get(1)?.let { "$it/01" }
}
return SChapter.create().apply {
chapter_number = 0F
setUrlWithoutDomain(element.selectFirst("link[rel=canonical]")!!.absUrl("href"))
name = "Gallery"
date_upload = FULL_DATE_FORMAT.tryParse(dateStr)
}
}
// endregion
// region Pages
override fun pageListParse(document: Document): List<Page> {
val basePageUrl = document.selectFirst("link[rel=canonical]")!!.absUrl("href")
val pages = mutableListOf<Page>()
document.select("div.post-inner div.page-link:nth-child(1) .post-page-numbers")
.forEachIndexed { index, pageEl ->
val doc = when (index) {
0 -> document
else -> {
val url = "$basePageUrl${pageEl.text()}/"
client.newCall(GET(url, headers)).execute().asJsoup()
}
}
doc.select("div.post-inner > div.entry > p > img")
.map { it.absUrl("data-src") }
.forEach { pages.add(Page(pages.size, imageUrl = it)) }
}
return pages
}
// endregion
override fun getFilterList(): FilterList = FilterList(
Filter.Header("NOTE: Unable to further search in the category!"),
Filter.Separator(),
SourceCategorySelector.create(),
)
companion object {
private val FULL_DATE_REGEX = Regex("""/(\d{4}/\d{2}/\d{2})/""")
private val YEAR_MONTH_REGEX = Regex("""/(\d{4}/\d{2})/""")
private val FULL_DATE_FORMAT = SimpleDateFormat("yyyy/MM/dd", Locale.US)
}
}

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.extension.all.misskon
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
abstract class SimpleParsedHttpSource : ParsedHttpSource() {
abstract fun simpleMangaSelector(): String
abstract fun simpleMangaFromElement(element: Element): SManga
abstract fun simpleNextPageSelector(): String?
// region popular
override fun popularMangaSelector() = simpleMangaSelector()
override fun popularMangaNextPageSelector() = simpleNextPageSelector()
override fun popularMangaFromElement(element: Element) = simpleMangaFromElement(element)
// endregion
// region last
override fun latestUpdatesSelector() = simpleMangaSelector()
override fun latestUpdatesFromElement(element: Element) = simpleMangaFromElement(element)
override fun latestUpdatesNextPageSelector() = simpleNextPageSelector()
// endregion
// region search
override fun searchMangaSelector() = simpleMangaSelector()
override fun searchMangaFromElement(element: Element) = simpleMangaFromElement(element)
override fun searchMangaNextPageSelector() = simpleNextPageSelector()
// endregion
override fun chapterListSelector() = simpleMangaSelector()
override fun imageUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
// endregion
}

View File

@ -0,0 +1,128 @@
package eu.kanade.tachiyomi.extension.all.misskon
import eu.kanade.tachiyomi.extension.all.misskon.SourceCategorySelector.Companion.CategoryPresets.CHINESE
import eu.kanade.tachiyomi.extension.all.misskon.SourceCategorySelector.Companion.CategoryPresets.KOREAN
import eu.kanade.tachiyomi.extension.all.misskon.SourceCategorySelector.Companion.CategoryPresets.OTHER
import eu.kanade.tachiyomi.extension.all.misskon.SourceCategorySelector.Companion.CategoryPresets.TOP
import eu.kanade.tachiyomi.source.model.Filter
data class SourceCategory(private val name: String, val url: String) {
override fun toString() = this.name
}
class SourceCategorySelector(
name: String,
categories: List<SourceCategory>,
) : Filter.Select<SourceCategory>(name, categories.toTypedArray()) {
val selectedCategory: SourceCategory?
get() = if (state > 0) values[state] else null
companion object {
private object CategoryPresets {
val TOP = listOf(
SourceCategory("Top 3 days", "/top3/"),
SourceCategory("Top 7 days", "/top7/"),
SourceCategory("Top 30 days", "/top30/"),
SourceCategory("Top 60 days", "/top60/"),
)
val CHINESE = listOf(
SourceCategory("Chinese:[MTCos] 喵糖映画", "/tag/mtcos/"),
SourceCategory("Chinese:BoLoli", "/tag/bololi/"),
SourceCategory("Chinese:CANDY", "/tag/candy/"),
SourceCategory("Chinese:FEILIN", "/tag/feilin/"),
SourceCategory("Chinese:FToow", "/tag/ftoow/"),
SourceCategory("Chinese:GIRLT", "/tag/girlt/"),
SourceCategory("Chinese:HuaYan", "/tag/huayan/"),
SourceCategory("Chinese:HuaYang", "/tag/huayang/"),
SourceCategory("Chinese:IMISS", "/tag/imiss/"),
SourceCategory("Chinese:ISHOW", "/tag/ishow/"),
SourceCategory("Chinese:JVID", "/tag/jvid/"),
SourceCategory("Chinese:KelaGirls", "/tag/kelagirls/"),
SourceCategory("Chinese:Kimoe", "/tag/kimoe/"),
SourceCategory("Chinese:LegBaby", "/tag/legbaby/"),
SourceCategory("Chinese:MF", "/tag/mf/"),
SourceCategory("Chinese:MFStar", "/tag/mfstar/"),
SourceCategory("Chinese:MiiTao", "/tag/miitao/"),
SourceCategory("Chinese:MintYe", "/tag/mintye/"),
SourceCategory("Chinese:MISSLEG", "/tag/missleg/"),
SourceCategory("Chinese:MiStar", "/tag/mistar/"),
SourceCategory("Chinese:MTMeng", "/tag/mtmeng/"),
SourceCategory("Chinese:MyGirl", "/tag/mygirl/"),
SourceCategory("Chinese:PartyCat", "/tag/partycat/"),
SourceCategory("Chinese:QingDouKe", "/tag/qingdouke/"),
SourceCategory("Chinese:RuiSG", "/tag/ruisg/"),
SourceCategory("Chinese:SLADY", "/tag/slady/"),
SourceCategory("Chinese:TASTE", "/tag/taste/"),
SourceCategory("Chinese:TGOD", "/tag/tgod/"),
SourceCategory("Chinese:TouTiao", "/tag/toutiao/"),
SourceCategory("Chinese:TuiGirl", "/tag/tuigirl/"),
SourceCategory("Chinese:Tukmo", "/tag/tukmo/"),
SourceCategory("Chinese:UGIRLS", "/tag/ugirls/"),
SourceCategory("Chinese:UGIRLS - Ai You Wu App", "/tag/ugirls-ai-you-wu-app/"),
SourceCategory("Chinese:UXING", "/tag/uxing/"),
SourceCategory("Chinese:WingS", "/tag/wings/"),
SourceCategory("Chinese:XiaoYu", "/tag/xiaoyu/"),
SourceCategory("Chinese:XingYan", "/tag/xingyan/"),
SourceCategory("Chinese:XIUREN", "/tag/xiuren/"),
SourceCategory("Chinese:XR Uncensored", "/tag/xr-uncensored/"),
SourceCategory("Chinese:YouMei", "/tag/youmei/"),
SourceCategory("Chinese:YouMi", "/tag/youmi/"),
SourceCategory("Chinese:YouMi尤蜜", "/tag/youmiapp/"),
SourceCategory("Chinese:YouWu", "/tag/youwu/"),
)
val KOREAN = listOf(
SourceCategory("Korean:AG", "/tag/ag/"),
SourceCategory("Korean:Bimilstory", "/tag/bimilstory/"),
SourceCategory("Korean:BLUECAKE", "/tag/bluecake/"),
SourceCategory("Korean:CreamSoda", "/tag/creamsoda/"),
SourceCategory("Korean:DJAWA", "/tag/djawa/"),
SourceCategory("Korean:Espacia Korea", "/tag/espacia-korea/"),
SourceCategory("Korean:Fantasy Factory", "/tag/fantasy-factory/"),
SourceCategory("Korean:Fantasy Story", "/tag/fantasy-story/"),
SourceCategory("Korean:Glamarchive", "/tag/glamarchive/"),
SourceCategory("Korean:HIGH FANTASY", "/tag/high-fantasy/"),
SourceCategory("Korean:KIMLEMON", "/tag/kimlemon/"),
SourceCategory("Korean:KIREI", "/tag/kirei/"),
SourceCategory("Korean:KiSiA", "/tag/kisia/"),
SourceCategory("Korean:Korean Realgraphic", "/tag/korean-realgraphic/"),
SourceCategory("Korean:Lilynah", "/tag/lilynah/"),
SourceCategory("Korean:Lookas", "/tag/lookas/"),
SourceCategory("Korean:Loozy", "/tag/loozy/"),
SourceCategory("Korean:Moon Night Snap", "/tag/moon-night-snap/"),
SourceCategory("Korean:Paranhosu", "/tag/paranhosu/"),
SourceCategory("Korean:PhotoChips", "/tag/photochips/"),
SourceCategory("Korean:Pure Media", "/tag/pure-media/"),
SourceCategory("Korean:PUSSYLET", "/tag/pussylet/"),
SourceCategory("Korean:SAINT Photolife", "/tag/saint-photolife/"),
SourceCategory("Korean:SWEETBOX", "/tag/sweetbox/"),
SourceCategory("Korean:UHHUNG MAGAZINE", "/tag/uhhung-magazine/"),
SourceCategory("Korean:UMIZINE", "/tag/umizine/"),
SourceCategory("Korean:WXY ENT", "/tag/wxy-ent/"),
SourceCategory("Korean:Yo-U", "/tag/yo-u/"),
)
val OTHER = listOf(
SourceCategory("Other:AI Generated", "/tag/ai-generated/"),
SourceCategory("Other:Cosplay", "/tag/cosplay/"),
SourceCategory("Other:JP", "/tag/jp/"),
SourceCategory("Other:JVID", "/tag/jvid/"),
SourceCategory("Other:Patreon", "/tag/patreon/"),
)
}
fun create(): SourceCategorySelector {
val options = mutableListOf(SourceCategory("unselected", "")).apply {
addAll(TOP)
addAll(CHINESE)
addAll(KOREAN)
addAll(OTHER)
}
return SourceCategorySelector("Category", options)
}
}
}