diff --git a/src/zh/komiic/AndroidManifest.xml b/src/zh/komiic/AndroidManifest.xml new file mode 100644 index 000000000..5e9c2ef4d --- /dev/null +++ b/src/zh/komiic/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/src/zh/komiic/build.gradle b/src/zh/komiic/build.gradle new file mode 100644 index 000000000..ed7745b7e --- /dev/null +++ b/src/zh/komiic/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Komiic' + extClass = '.Komiic' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8b870210e Binary files /dev/null and b/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..807a945a9 Binary files /dev/null and b/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..672fe14ec Binary files /dev/null and b/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..9fbeef067 Binary files /dev/null and b/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b1b5eb591 Binary files /dev/null and b/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt new file mode 100644 index 000000000..5f407ea64 --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt @@ -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 parseComicList(response: Response): MangasPage { + val res = response.parseAs>() + 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(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(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>() + val entries = mutableListOf() + 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 { + 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>() + 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>() + 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 { + val res = response.parseAs>() + 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().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 { + val res = response.parseAs>() + 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 String.parseAs(): T = + json.decodeFromString(this) + + private inline fun Response.parseAs(): T = + use { body.string() }.parseAs() + + private inline fun 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") + } + } +} diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt new file mode 100644 index 000000000..f4c02454b --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.extension.zh.komiic + +import kotlinx.serialization.Serializable + +@Serializable +class Payload( + 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, +) diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt new file mode 100644 index 000000000..a8a2704a7 --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt @@ -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 + } + } + """ +} diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt new file mode 100644 index 000000000..14b33c015 --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt @@ -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(val data: T) + +interface ComicListResult { + val comics: List +} + +@Serializable +data class HotComicsResponse( + @SerialName("hotComics") override val comics: List, +) : ComicListResult + +@Serializable +data class RecentUpdateResponse( + @SerialName("recentUpdate") override val comics: List, +) : ComicListResult + +interface SearchResult { + val action: ComicsAndAuthors +} + +@Serializable +data class SearchResponse( + @SerialName("searchComicsAndAuthors") override val action: ComicsAndAuthors, +) : SearchResult + +@Serializable +data class ComicsAndAuthors( + val comics: List, + val authors: List, + @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, + val categories: List, + 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 +} + +@Serializable +data class ChaptersResponse( + @SerialName("chaptersByComicId") override val chapters: List, +) : 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, +) + +@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, +) diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt new file mode 100644 index 000000000..54be52071 --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt @@ -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) + } +} diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt new file mode 100644 index 000000000..5f5aff299 --- /dev/null +++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt @@ -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}億" + } +}