Add Komiic source (#5224)

* Init commit of the Komiic

* komiic: set ext to nsfw

* Komiic: Add fetchAPILimit

* Komiic: Refactor entire project.

* komiic: save date format as class val and wrap the parsing in try catch

* Comiic: remove unnecessary private function
rename some vars
remove unnecessary SerialName
remove unnecessary interface

* komiic:
add private val json
payload use simple classes
change some companion object to capital

* Komiic:
imports go ordered in lexicographic
commet function fetchAPILimit

* Komiic: restore previous import order

* Komiic: optimize some variables
This commit is contained in:
SummonHIM 2024-10-05 12:33:43 +08:00 committed by Draff
parent ced338c8e2
commit db21462070
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
13 changed files with 761 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".zh.komiic.UrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="komiic.com"
android:pathPattern="/comic/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,285 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
class Komiic : HttpSource() {
// Override variables
override var name = "Komiic"
override val baseUrl = "https://komiic.com"
override val lang = "zh"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
// Variables
private val queryAPIUrl = "$baseUrl/api/query"
private val json: Json by injectLazy()
/**
* 解析漫畫列表
* Parse comic list
*/
private inline fun <reified T : ComicListResult> parseComicList(response: Response): MangasPage {
val res = response.parseAs<Data<T>>()
val comics = res.data.comics
val entries = comics.map { comic ->
comic.toSManga()
}
val hasNextPage = comics.size == PAGE_SIZE
return MangasPage(entries, hasNextPage)
}
// Hot Comic
override fun popularMangaRequest(page: Int): Request {
val payload = Payload(
operationName = "hotComics",
variables = HotComicsVariables(
pagination = MangaListPagination(
PAGE_SIZE,
(page - 1) * PAGE_SIZE,
"MONTH_VIEWS",
"",
true,
),
),
query = QUERY_HOT_COMICS,
).toJsonRequestBody()
return POST(queryAPIUrl, headers, payload)
}
override fun popularMangaParse(response: Response) = parseComicList<HotComicsResponse>(response)
// Recent update
override fun latestUpdatesRequest(page: Int): Request {
val payload = Payload(
operationName = "recentUpdate",
variables = RecentUpdateVariables(
pagination = MangaListPagination(
PAGE_SIZE,
(page - 1) * PAGE_SIZE,
"DATE_UPDATED",
"",
true,
),
),
query = QUERY_RECENT_UPDATE,
).toJsonRequestBody()
return POST(queryAPIUrl, headers, payload)
}
override fun latestUpdatesParse(response: Response) = parseComicList<RecentUpdateResponse>(response)
/**
* 根據 ID 搜索漫畫
* Search the comic based on the ID.
*/
private fun comicByIDRequest(id: String): Request {
val payload = Payload(
operationName = "comicById",
variables = ComicByIdVariables(id),
query = QUERY_COMIC_BY_ID,
).toJsonRequestBody()
return POST(queryAPIUrl, headers, payload)
}
/**
* 根據 ID 解析搜索來的漫畫
* Parse the comic based on the ID.
*/
private fun parseComicByID(response: Response): MangasPage {
val res = response.parseAs<Data<ComicByIDResponse>>()
val entries = mutableListOf<SManga>()
val comic = res.data.comic.toSManga()
entries.add(comic)
val hasNextPage = entries.size == PAGE_SIZE
return MangasPage(entries, hasNextPage)
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val payload = Payload(
operationName = "searchComicAndAuthorQuery",
variables = SearchVariables(query),
query = QUERY_SEARCH,
).toJsonRequestBody()
return POST(queryAPIUrl, headers, payload)
}
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
val mangaId = query.substringAfter(PREFIX_ID_SEARCH)
client.newCall(comicByIDRequest(mangaId))
.asObservableSuccess()
.map(::parseComicByID)
} else {
super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaParse(response: Response): MangasPage {
val res = response.parseAs<Data<SearchResponse>>()
val comics = res.data.action.comics
val entries = comics.map { comic ->
comic.toSManga()
}
val hasNextPage = comics.size == PAGE_SIZE
return MangasPage(entries, hasNextPage)
}
// Comic details
override fun mangaDetailsRequest(manga: SManga) = comicByIDRequest(manga.url.substringAfterLast("/"))
override fun mangaDetailsParse(response: Response): SManga {
val res = response.parseAs<Data<ComicByIDResponse>>()
val comic = res.data.comic.toSManga()
return comic
}
override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}"
/**
* 解析日期
* Parse date
*/
private fun parseDate(dateStr: String): Long {
return try {
DATE_FORMAT.parse(dateStr)?.time ?: 0L
} catch (e: ParseException) {
e.printStackTrace()
0L
}
}
// Chapter list
override fun chapterListRequest(manga: SManga): Request {
val payload = Payload(
operationName = "chapterByComicId",
variables = ChapterByComicIdVariables(manga.url.substringAfterLast("/")),
query = QUERY_CHAPTER,
).toJsonRequestBody()
return POST("$queryAPIUrl#${manga.url}", headers, payload)
}
override fun chapterListParse(response: Response): List<SChapter> {
val res = response.parseAs<Data<ChaptersResponse>>()
val comics = res.data.chapters
val comicUrl = response.request.url.fragment
val tChapters = comics.filter { it.type == "chapter" }
val tBooks = comics.filter { it.type == "book" }
val entries = (tChapters + tBooks).map { chapter ->
SChapter.create().apply {
url = "$comicUrl/chapter/${chapter.id}/page/1"
name = when (chapter.type) {
"chapter" -> "${chapter.serial}"
"book" -> "${chapter.serial}"
else -> chapter.serial
}
date_upload = parseDate(chapter.dateCreated)
chapter_number = chapter.serial.toFloatOrNull() ?: -1f
}
}.reversed()
return entries
}
/**
* 檢查 API 是否達到上限
* Check if the API has reached its limit.
*
* (Idk how to throw an exception in reading page)
*/
// private fun fetchAPILimit(): Boolean {
// val payload = Payload("getImageLimit", "", QUERY_API_LIMIT).toJsonRequestBody()
// val response = client.newCall(POST(queryAPIUrl, headers, payload)).execute()
// val limit = response.parseAs<APILimitData>().getImageLimit
// return limit.limit <= limit.usage
// }
// Page list
override fun pageListRequest(chapter: SChapter): Request {
val payload = Payload(
operationName = "imagesByChapterId",
variables = ImagesByChapterIdVariables(
chapter.url.substringAfter("/chapter/").substringBefore("/page/"),
),
query = QUERY_PAGE_LIST,
).toJsonRequestBody()
return POST("$queryAPIUrl#${chapter.url}", headers, payload)
}
override fun pageListParse(response: Response): List<Page> {
val res = response.parseAs<Data<ImagesResponse>>()
val pages = res.data.images
val chapterUrl = response.request.url.toString().split("#")[1]
return pages.mapIndexed { index, image ->
Page(
index,
"${chapterUrl.substringBeforeLast("/")}/$index",
"$baseUrl/api/image/${image.kid}",
)
}
}
override fun imageRequest(page: Page): Request {
return super.imageRequest(page).newBuilder()
.addHeader("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'")
.addHeader("referer", page.url)
.build()
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
private inline fun <reified T> String.parseAs(): T =
json.decodeFromString(this)
private inline fun <reified T> Response.parseAs(): T =
use { body.string() }.parseAs()
private inline fun <reified T : Any> T.toJsonRequestBody(): RequestBody =
json.encodeToString(this)
.toRequestBody(JSON_MEDIA_TYPE)
companion object {
private const val PAGE_SIZE = 20
const val PREFIX_ID_SEARCH = "id:"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import kotlinx.serialization.Serializable
@Serializable
class Payload<T>(
val operationName: String,
val variables: T,
val query: String,
)
@Serializable
data class MangaListPagination(
val limit: Int,
val offset: Int,
val orderBy: String,
val status: String,
val asc: Boolean,
)
@Serializable
data class HotComicsVariables(
val pagination: MangaListPagination,
)
@Serializable
data class RecentUpdateVariables(
val pagination: MangaListPagination,
)
@Serializable
data class SearchVariables(
val keyword: String,
)
@Serializable
data class ComicByIdVariables(
val comicId: String,
)
@Serializable
data class ChapterByComicIdVariables(
val comicId: String,
)
@Serializable
data class ImagesByChapterIdVariables(
val chapterId: String,
)

View File

@ -0,0 +1,187 @@
package eu.kanade.tachiyomi.extension.zh.komiic
private fun buildQuery(queryAction: () -> String): String {
return queryAction()
.trimIndent()
.replace("%", "$")
}
val QUERY_HOT_COMICS: String = buildQuery {
"""
query hotComics(%pagination: Pagination!) {
hotComics(pagination: %pagination) {
id
title
status
year
imageUrl
authors {
id
name
__typename
}
categories {
id
name
__typename
}
dateUpdated
monthViews
views
favoriteCount
lastBookUpdate
lastChapterUpdate
__typename
}
}
"""
}
val QUERY_RECENT_UPDATE: String = buildQuery {
"""
query recentUpdate(%pagination: Pagination!) {
recentUpdate(pagination: %pagination) {
id
title
status
year
imageUrl
authors {
id
name
__typename
}
categories {
id
name
__typename
}
dateUpdated
monthViews
views
favoriteCount
lastBookUpdate
lastChapterUpdate
__typename
}
}
"""
}
val QUERY_SEARCH: String = buildQuery {
"""
query searchComicAndAuthorQuery(%keyword: String!) {
searchComicsAndAuthors(keyword: %keyword) {
comics {
id
title
status
year
imageUrl
authors {
id
name
__typename
}
categories {
id
name
__typename
}
dateUpdated
monthViews
views
favoriteCount
lastBookUpdate
lastChapterUpdate
__typename
}
authors {
id
name
chName
enName
wikiLink
comicCount
views
__typename
}
__typename
}
}
"""
}
val QUERY_CHAPTER: String = buildQuery {
"""
query chapterByComicId(%comicId: ID!) {
chaptersByComicId(comicId: %comicId) {
id
serial
type
dateCreated
dateUpdated
size
__typename
}
}
"""
}
val QUERY_COMIC_BY_ID = buildQuery {
"""
query comicById(%comicId: ID!) {
comicById(comicId: %comicId) {
id
title
status
year
imageUrl
authors {
id
name
__typename
}
categories {
id
name
__typename
}
dateCreated
dateUpdated
views
favoriteCount
lastBookUpdate
lastChapterUpdate
__typename
}
}
"""
}
val QUERY_PAGE_LIST = buildQuery {
"""
query imagesByChapterId(%chapterId: ID!) {
imagesByChapterId(chapterId: %chapterId) {
id
kid
height
width
__typename
}
}
"""
}
val QUERY_API_LIMIT = buildQuery {
"""
query getImageLimit {
getImageLimit {
limit
usage
resetInSeconds
__typename
}
}
"""
}

View File

@ -0,0 +1,160 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Data<T>(val data: T)
interface ComicListResult {
val comics: List<Comic>
}
@Serializable
data class HotComicsResponse(
@SerialName("hotComics") override val comics: List<Comic>,
) : ComicListResult
@Serializable
data class RecentUpdateResponse(
@SerialName("recentUpdate") override val comics: List<Comic>,
) : ComicListResult
interface SearchResult {
val action: ComicsAndAuthors
}
@Serializable
data class SearchResponse(
@SerialName("searchComicsAndAuthors") override val action: ComicsAndAuthors,
) : SearchResult
@Serializable
data class ComicsAndAuthors(
val comics: List<Comic>,
val authors: List<Author>,
@SerialName("__typename") val typeName: String,
)
interface ComicResult {
val comic: Comic
}
@Serializable
data class ComicByIDResponse(
@SerialName("comicById") override val comic: Comic,
) : ComicResult
@Serializable
data class Comic(
val id: String,
val title: String,
val status: String,
val year: Int,
val imageUrl: String,
var authors: List<ComicAuthor>,
val categories: List<ComicCategory>,
val dateCreated: String = "",
val dateUpdated: String,
val monthViews: Int = 0,
val views: Int,
val favoriteCount: Int,
val lastBookUpdate: String,
val lastChapterUpdate: String,
@SerialName("__typename") val typeName: String,
) {
private val parseStatus = when (status) {
"ONGOING" -> SManga.ONGOING
"END" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
fun toSManga() = SManga.create().apply {
url = "/comic/$id"
title = this@Comic.title
thumbnail_url = this@Comic.imageUrl
author = this@Comic.authors.joinToString { it.name }
genre = this@Comic.categories.joinToString { it.name }
description = buildString {
append("年份: $year | ")
append("點閱: ${simplifyNumber(views)} | ")
append("喜愛: ${simplifyNumber(favoriteCount)}\n")
}
status = parseStatus
initialized = true
}
}
@Serializable
data class ComicCategory(
val id: String,
val name: String,
@SerialName("__typename") val typeName: String,
)
@Serializable
data class ComicAuthor(
val id: String,
val name: String,
@SerialName("__typename") val typeName: String,
)
@Serializable
data class Author(
val id: String,
val name: String,
val chName: String,
val enName: String,
val wikiLink: String,
val comicCount: Int,
val views: Int,
@SerialName("__typename") val typeName: String,
)
interface ChaptersResult {
val chapters: List<Chapter>
}
@Serializable
data class ChaptersResponse(
@SerialName("chaptersByComicId") override val chapters: List<Chapter>,
) : ChaptersResult
@Serializable
data class Chapter(
val id: String,
val serial: String,
val type: String,
val dateCreated: String,
val dateUpdated: String,
val size: Int,
@SerialName("__typename") val typeName: String,
)
@Serializable
data class ImagesResponse(
@SerialName("imagesByChapterId") val images: List<Image>,
)
@Serializable
data class Image(
val id: String,
val kid: String,
val height: Int,
val width: Int,
@SerialName("__typename") val typeName: String,
)
@Serializable
data class APILimitData(
@SerialName("getImageLimit") val getImageLimit: APILimit,
)
@Serializable
data class APILimit(
val limit: Int,
val usage: Int,
val resetInSeconds: String,
@SerialName("__typename") val typeName: String,
)

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class UrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val id = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Komiic.PREFIX_ID_SEARCH}$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("KomiicUrlActivity", e.toString())
}
} else {
Log.e("KomiicUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import kotlin.math.abs
/**
* 簡化數字顯示
*/
fun simplifyNumber(num: Int): String {
return when {
abs(num) < 1000 -> "$num"
abs(num) < 10000 -> "${num / 1000}"
abs(num) < 100000000 -> "${num / 10000}"
else -> "${num / 100000000}"
}
}