MCCMS: rewrite and add new sources (#12531)

This commit is contained in:
stevenyomi 2022-07-11 06:16:16 +08:00 committed by GitHub
parent 2f16f8c880
commit 6683290bdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 269 additions and 154 deletions

View File

@ -1,7 +1,10 @@
package eu.kanade.tachiyomi.extension.zh.haoman6 package eu.kanade.tachiyomi.extension.zh.haoman6
import eu.kanade.tachiyomi.multisrc.mccms.MCCMS import eu.kanade.tachiyomi.multisrc.mccms.MCCMS
import eu.kanade.tachiyomi.source.model.SManga
class Haoman6 : MCCMS("好漫6", "https://www.haoman6.com") { class Haoman6 : MCCMS("好漫6", "https://www.haoman6.com") {
override fun transformTitle(title: String) = title.removeSuffix("(最新在线)").removeSuffix("-") override fun SManga.cleanup() = apply {
title = title.removeSuffix("(最新在线)").removeSuffix("-")
}
} }

View File

@ -1,13 +1,12 @@
package eu.kanade.tachiyomi.extension.zh.haoman6_glens package eu.kanade.tachiyomi.extension.zh.haoman6_glens
import eu.kanade.tachiyomi.multisrc.mccms.MCCMS import eu.kanade.tachiyomi.multisrc.mccms.MCCMS
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.SManga
import okhttp3.Response
class Haoman6_glens : MCCMS("好漫6 (g-lens)", "https://www.g-lens.com") { class Haoman6_glens : MCCMS("好漫6 (g-lens)", "https://www.g-lens.com") {
override fun transformTitle(title: String) = title.removeSuffix("_").removeSuffix("-") override fun SManga.cleanup() = apply {
override val lazyLoadImageAttr = "pc-ec" title = title.removeSuffix("_").removeSuffix("-")
}
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/category/order/addtime", headers) override val lazyLoadImageAttr = "pc-ec"
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
} }

View File

@ -1,14 +1,7 @@
package eu.kanade.tachiyomi.extension.zh.haoman8 package eu.kanade.tachiyomi.extension.zh.haoman8
import eu.kanade.tachiyomi.multisrc.mccms.MCCMS import eu.kanade.tachiyomi.multisrc.mccms.MCCMS
import eu.kanade.tachiyomi.network.GET
class Haoman8 : MCCMS("好漫8", "https://caiji.haoman8.com") { class Haoman8 : MCCMS("好漫8", "https://www.haoman8.com") {
override val lazyLoadImageAttr = "data-echo"
// Search: 此站点nginx配置有问题只能用以下格式搜索第一页
override fun textSearchRequest(page: Int, query: String) =
GET("$baseUrl/index.php/search?key=$query", headers)
override fun searchMangaNextPageSelector(): String? = null
} }

View File

@ -1,32 +1,15 @@
package eu.kanade.tachiyomi.extension.zh.haomanwu package eu.kanade.tachiyomi.extension.zh.haomanwu
import eu.kanade.tachiyomi.multisrc.mccms.MCCMS import eu.kanade.tachiyomi.multisrc.mccms.MCCMS
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SManga import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
class Haomanwu : MCCMS("好漫屋", "https://app2.haoman6.com") { class Haomanwu : MCCMS("好漫屋", "https://app2.haoman6.com", hasCategoryPage = false) {
override fun pageListParse(response: Response): List<Page> {
// Search val pages = super.pageListParse(response)
override fun searchMangaNextPageSelector() = "li:nth-child(30) > a" // 有30项则可能有下一页
override fun searchMangaSelector() = "li > a"
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
title = element.text()
setUrlWithoutDomain(element.attr("abs:href"))
}
override fun pageListParse(document: Document): List<Page> {
val pages = super.pageListParse(document)
if (pages.any { it.imageUrl!!.endsWith("tianjia.jpg") }) { if (pages.any { it.imageUrl!!.endsWith("tianjia.jpg") }) {
throw Exception("该章节有图片尚未添加") throw Exception("该章节有图片尚未添加")
} }
return pages return pages
} }
// 分类页面缺失
override fun fetchCategories() = Unit
override fun getFilterList() = FilterList(emptyList())
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.extension.zh.haomanwu_www
import eu.kanade.tachiyomi.multisrc.mccms.MCCMS
class Haomanwu_www : MCCMS("好漫屋 (网页)", "https://www.haomanwu.com") {
override val lazyLoadImageAttr = "data-echo"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -2,166 +2,148 @@ package eu.kanade.tachiyomi.multisrc.mccms
import android.util.Log import android.util.Log
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import rx.Observable
import org.jsoup.nodes.Element import rx.Single
import uy.kohesive.injekt.injectLazy
import kotlin.concurrent.thread import kotlin.concurrent.thread
/** /**
* 漫城CMS http://mccms.cn/ * 漫城CMS http://mccms.cn/
*/ */
abstract class MCCMS( open class MCCMS(
override val name: String, override val name: String,
override val baseUrl: String, override val baseUrl: String,
override val lang: String = "zh", override val lang: String = "zh",
) : ParsedHttpSource() { hasCategoryPage: Boolean = true
override val supportsLatest: Boolean = true ) : HttpSource() {
override val supportsLatest = true
protected open fun transformTitle(title: String) = title private val json: Json by injectLazy()
// Popular override val client by lazy {
network.client.newBuilder()
override fun popularMangaRequest(page: Int) = GET("$baseUrl/custom/hot", headers) .rateLimitHost(baseUrl.toHttpUrl(), 2)
override fun popularMangaNextPageSelector(): String? = null .build()
override fun popularMangaSelector() = ".top-list__box-item"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
val titleElement = element.select("p.comic__title > a")
title = transformTitle(titleElement.text().trim())
setUrlWithoutDomain(titleElement.attr("abs:href"))
thumbnail_url = element.select("img").attr("abs:data-original")
} }
// Latest private val pcHeaders by lazy { super.headersBuilder().build() }
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/custom/update", headers) override fun headersBuilder() = Headers.Builder()
override fun latestUpdatesNextPageSelector(): String? = null .add("User-Agent", System.getProperty("http.agent")!!)
override fun latestUpdatesSelector() = "div.common-comic-item" .add("Referer", baseUrl)
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
// Search protected open fun SManga.cleanup(): SManga = this
protected open fun textSearchRequest(page: Int, query: String) = override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/search/$query/$page", headers) GET("$baseUrl/api/data/comic?page=$page&size=$PAGE_SIZE&order=hits", headers)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = override fun popularMangaParse(response: Response): MangasPage {
if (query.isNotBlank()) { val list: List<MangaDto> = response.parseAs()
textSearchRequest(page, query) return MangasPage(list.map { it.toSManga().cleanup() }, list.size >= PAGE_SIZE)
} else { }
val categories = filters.filterIsInstance<UriPartFilter>()
.map { it.toUriPart() }.filter { it.isNotEmpty() }.joinToString("/") override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/category/$categories/page/$page", headers) GET("$baseUrl/api/data/comic?page=$page&size=$PAGE_SIZE&order=addtime", headers)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val queries = buildList {
add("page=$page")
add("size=$PAGE_SIZE")
val isTextSearch = query.isNotBlank()
if (isTextSearch) add("key=$query")
for (filter in filters) if (filter is MCCMSFilter) {
if (isTextSearch && filter.isTypeQuery) continue
val part = filter.query
if (part.isNotEmpty()) add(part)
}
} }
val url = buildString {
override fun searchMangaNextPageSelector(): String? = "" // empty string means default pagination append(baseUrl).append("/api/data/comic?")
override fun searchMangaSelector() = latestUpdatesSelector() queries.joinTo(this, separator = "&")
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val isTextSearch = document.location().contains("search")
val mangas = if (isTextSearch) {
document.select(searchMangaSelector()).map { searchMangaFromElement(it) }
} else {
document.select(latestUpdatesSelector()).map { popularMangaFromElement(it) }
} }
val hasNextPage = if (isTextSearch && searchMangaNextPageSelector() != "") { return GET(url, headers)
searchMangaNextPageSelector()?.let { document.selectFirst(it) } != null
} else { // default pagination
val buttons = document.select("#Pagination a")
val count = buttons.size
// Next page != Last page
buttons[count - 1].attr("href") != buttons[count - 2].attr("href")
}
return MangasPage(mangas, hasNextPage)
} }
// Details override fun searchMangaParse(response: Response) = popularMangaParse(response)
override fun mangaDetailsParse(document: Document) = SManga.create().apply { // preserve mangaDetailsRequest for WebView
title = transformTitle(document.select("div.de-info__box > p.comic-title").text().trim()) override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
thumbnail_url = document.select("div.de-info__cover > img").attr("abs:src") client.newCall(GET("$baseUrl/api/data/comic?key=${manga.title}", headers))
author = document.select("div.comic-author > span.name > a").text() .asObservableSuccess().map { response ->
artist = author val list: List<MangaDto> = response.parseAs()
genre = document.select("div.comic-status > span.text:nth-child(1) a").eachText().joinToString(", ") list.find { it.url == manga.url }!!.toSManga().cleanup()
description = document.select("div.comic-intro > p.intro-total").text() }
}
// Chapters override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException("Not used.")
override fun chapterListSelector() = "ul.chapter__list-box > li" override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Single.create<List<SChapter>> { subscriber ->
override fun chapterFromElement(element: Element) = SChapter.create().apply { val id = manga.url.substringAfterLast('/')
setUrlWithoutDomain(element.select("a").attr("abs:href")) val dataResponse = client.newCall(GET("$baseUrl/api/data/chapter?mid=$id", headers)).execute()
name = element.select("a").text() val dataList: List<ChapterDataDto> = dataResponse.parseAs() // unordered
} val dateMap = HashMap<Int, Long>(dataList.size * 2)
dataList.forEach { dateMap[it.id.toInt()] = it.date }
val response = client.newCall(GET("$baseUrl/api/comic/chapter?mid=$id", headers)).execute()
val list: List<ChapterDto> = response.parseAs()
val result = list.map { it.toSChapter(date = dateMap[it.id.toInt()] ?: 0) }.asReversed()
subscriber.onSuccess(result)
}.toObservable()
override fun chapterListParse(response: Response) = super.chapterListParse(response).reversed() override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used.")
// Pages override fun pageListRequest(chapter: SChapter): Request =
GET(baseUrl + chapter.url, pcHeaders)
protected open val lazyLoadImageAttr = "data-original" protected open val lazyLoadImageAttr = "data-original"
override fun pageListParse(document: Document) = document.select("div.rd-article__pic > img") override fun pageListParse(response: Response): List<Page> {
.mapIndexed { i, el -> Page(i, "", el.attr("abs:$lazyLoadImageAttr")) } val document = response.asJsoup()
return document.select("img[$lazyLoadImageAttr]").mapIndexed { i, element ->
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.") Page(i, imageUrl = element.attr(lazyLoadImageAttr))
}
protected class UriPartFilter(name: String, values: Array<String>, private val uriParts: Array<String>) :
Filter.Select<String>(name, values) {
fun toUriPart(): String = uriParts[state]
} }
protected data class Category(val name: String, val values: Array<String>, val uriParts: Array<String>) { override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
fun toUriPartFilter() = UriPartFilter(name, values, uriParts)
private inline fun <reified T> Response.parseAs(): T = use {
@Suppress("OPT_IN_USAGE")
json.decodeFromStream<ResultDto<T>>(it.body!!.byteStream()).data
} }
private val sortCategory = Category("排序", arrayOf("热门人气", "更新时间"), arrayOf("order/hits", "order/addtime")) private val genreData = GenreData(hasCategoryPage)
private lateinit var categories: List<Category>
private var isFetchingCategories = false
private fun tryFetchCategories() { private fun fetchGenres() {
if (isFetchingCategories) return if (genreData.status != GenreData.NOT_FETCHED) return
isFetchingCategories = true genreData.status = GenreData.FETCHING
thread { thread {
try { try {
fetchCategories() val response = client.newCall(GET("$baseUrl/category/", pcHeaders)).execute()
parseGenres(response.asJsoup(), genreData)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MCCMS/$name", "Failed to fetch categories ($e)") genreData.status = GenreData.NOT_FETCHED
} finally { Log.e("MCCMS/$name", "failed to fetch genres", e)
isFetchingCategories = false
} }
} }
} }
protected open fun fetchCategories() {
val document = client.newCall(GET("$baseUrl/category/", headers)).execute().asJsoup()
categories = document.select("div.cate-col").map { element ->
val name = element.select("p.cate-title").text().removeSuffix("")
val tags = element.select("li.cate-item > a")
val values = tags.map { it.text() }.toTypedArray()
val uriParts = tags.map { it.attr("href").removePrefix("/category/") }.toTypedArray()
Category(name, values, uriParts)
}
}
override fun getFilterList(): FilterList { override fun getFilterList(): FilterList {
val result = mutableListOf( fetchGenres()
Filter.Header("如果使用文本搜索,将会忽略分类筛选"), return getFilters(genreData)
sortCategory.toUriPartFilter(),
)
if (::categories.isInitialized) {
categories.forEach { result.add(it.toUriPartFilter()) }
} else {
tryFetchCategories()
result.add(Filter.Header("其他分类正在获取,请返回上一页后重试"))
}
return FilterList(result)
} }
} }

View File

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.multisrc.mccms
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
internal const val PAGE_SIZE = 30
@Serializable
class MangaDto(
private val name: String,
private val pic: String,
private val serialize: String,
private val author: String,
private val content: String,
private val addtime: String,
val url: String,
private val tags: List<String>,
) {
fun toSManga() = SManga.create().apply {
url = this@MangaDto.url
title = name
author = this@MangaDto.author
description = content
genre = tags.joinToString()
val date = dateFormat.parse(addtime)?.time ?: 0
val isUpdating = System.currentTimeMillis() - date <= 30L * 24 * 3600 * 1000 // a month
status = when {
serialize.startsWith('连') || isUpdating -> SManga.ONGOING
serialize.startsWith('完') -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url = pic
initialized = true
}
companion object {
private val dateFormat by lazy { getDateFormat() }
}
}
@Serializable
class ChapterDto(val id: String, private val name: String, private val link: String) {
fun toSChapter(date: Long) = SChapter.create().apply {
url = link
name = this@ChapterDto.name
date_upload = date
}
}
@Serializable
class ChapterDataDto(val id: String, private val addtime: String) {
val date get() = dateFormat.parse(addtime)?.time ?: 0
companion object {
private val dateFormat by lazy { getDateFormat() }
}
}
@Serializable
class ResultDto<T>(val data: T)
fun getDateFormat() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.multisrc.mccms
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import org.jsoup.nodes.Document
open class MCCMSFilter(
name: String,
values: Array<String>,
private val queries: Array<String>,
val isTypeQuery: Boolean = false,
) : Filter.Select<String>(name, values) {
val query get() = queries[state]
}
class SortFilter : MCCMSFilter("排序", SORT_NAMES, SORT_QUERIES)
private val SORT_NAMES = arrayOf("热门人气", "更新时间", "评分")
private val SORT_QUERIES = arrayOf("order=hits", "order=addtime", "order=score")
class StatusFilter : MCCMSFilter("进度", STATUS_NAMES, STATUS_QUERIES)
private val STATUS_NAMES = arrayOf("全部", "连载(有缺漏)", "完结(有缺漏)")
private val STATUS_QUERIES = arrayOf("", "serialize=连载", "serialize=完结")
class GenreFilter(private val values: Array<String>, private val queries: Array<String>) {
val filter get() = MCCMSFilter("标签(搜索文本时无效)", values, queries, isTypeQuery = true)
}
class GenreData(hasCategoryPage: Boolean) {
var status = if (hasCategoryPage) NOT_FETCHED else NO_DATA
lateinit var genreFilter: GenreFilter
companion object {
const val NOT_FETCHED = 0
const val FETCHING = 1
const val FETCHED = 2
const val NO_DATA = 3
}
}
internal fun parseGenres(document: Document, genreData: GenreData) {
val genres = document.select("a[href^=/category/tags/]")
if (genres.isEmpty()) {
genreData.status = GenreData.NO_DATA
return
}
val result = buildList(genres.size + 1) {
add(Pair("全部", ""))
genres.mapTo(this) {
val tagId = it.attr("href").substringAfterLast('/')
Pair(it.text(), "type[tags]=$tagId")
}
}
genreData.genreFilter = GenreFilter(
values = result.map { it.first }.toTypedArray(),
queries = result.map { it.second }.toTypedArray(),
)
genreData.status = GenreData.FETCHED
}
internal fun getFilters(genreData: GenreData): FilterList {
val list = buildList(4) {
add(StatusFilter())
add(SortFilter())
if (genreData.status == GenreData.NO_DATA) return@buildList
add(Filter.Separator())
if (genreData.status == GenreData.FETCHED) {
add(genreData.genreFilter.filter)
} else {
add(Filter.Header("点击“重置”尝试刷新标签分类"))
}
}
return FilterList(list)
}

View File

@ -6,13 +6,13 @@ import generator.ThemeSourceGenerator
class MCCMSGenerator : ThemeSourceGenerator { class MCCMSGenerator : ThemeSourceGenerator {
override val themeClass = "MCCMS" override val themeClass = "MCCMS"
override val themePkg = "mccms" override val themePkg = "mccms"
override val baseVersionCode = 2 override val baseVersionCode = 3
override val sources = listOf( override val sources = listOf(
SingleLang( SingleLang(
name = "Haoman6", baseUrl = "https://www.haoman6.com", lang = "zh", name = "Haoman6", baseUrl = "https://www.haoman6.com", lang = "zh",
className = "Haoman6", sourceName = "好漫6", overrideVersionCode = 2 className = "Haoman6", sourceName = "好漫6", overrideVersionCode = 2
), ),
SingleLang( SingleLang( // 与 app2.haomanwu.com 相同
name = "Haomanwu", baseUrl = "https://app2.haoman6.com", lang = "zh", name = "Haomanwu", baseUrl = "https://app2.haoman6.com", lang = "zh",
className = "Haomanwu", sourceName = "好漫屋", overrideVersionCode = 3 className = "Haomanwu", sourceName = "好漫屋", overrideVersionCode = 3
), ),
@ -20,10 +20,18 @@ class MCCMSGenerator : ThemeSourceGenerator {
name = "Haoman6 (g-lens)", baseUrl = "https://www.g-lens.com", lang = "zh", name = "Haoman6 (g-lens)", baseUrl = "https://www.g-lens.com", lang = "zh",
className = "Haoman6_glens", sourceName = "好漫6 (g-lens)", overrideVersionCode = 0 className = "Haoman6_glens", sourceName = "好漫6 (g-lens)", overrideVersionCode = 0
), ),
SingleLang( SingleLang( // 与 caiji.haoman8.com 相同
name = "Haoman8", baseUrl = "https://caiji.haoman8.com", lang = "zh", name = "Haoman8", baseUrl = "https://www.haoman8.com", lang = "zh",
className = "Haoman8", sourceName = "好漫8", overrideVersionCode = 0 className = "Haoman8", sourceName = "好漫8", overrideVersionCode = 0
), ),
SingleLang(
name = "Haomanwu (www)", baseUrl = "https://www.haomanwu.com", lang = "zh",
className = "Haomanwu_www", sourceName = "好漫屋 (网页)", overrideVersionCode = 0
),
SingleLang( // 与 app.manhuaorg.com 相同部分渠道记为“好漫2”
name = "Pupu Manhua", baseUrl = "https://www.manhuaorg.com", lang = "zh",
className = "Manhuaorg", sourceName = "朴朴漫画", overrideVersionCode = 0
),
) )
companion object { companion object {