Add Graphite Comics source (closes #8598). (#8603)

This commit is contained in:
Alessandro Jean 2021-08-18 07:34:15 -03:00 committed by GitHub
parent a6bc09b9e9
commit c69c777fee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 345 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Graphite Comics'
pkgNameSuffix = 'en.graphitecomics'
extClass = '.GraphiteComics'
extVersionCode = 1
libVersion = '1.2'
}
dependencies {
implementation project(':lib-ratelimit')
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -0,0 +1,270 @@
package eu.kanade.tachiyomi.extension.en.graphitecomics
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET
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.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.lang.UnsupportedOperationException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
class GraphiteComics : HttpSource() {
override val name = "Graphite Comics"
override val baseUrl = "http://graphitecomics.com"
override val lang = "en"
override val supportsLatest = false
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS))
.build()
private val json: Json by injectLazy()
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.add("Accept", ACCEPT_ALL)
.add("Origin", baseUrl)
.add("Referer", "$baseUrl/")
private fun genericComicBookFromObject(comic: GraphiteComic): SManga =
SManga.create().apply {
title = comic.name
url = "/title/${comic.publisherSlug}/${comic.slug}"
thumbnail_url = comic.logo?.url
}
override fun popularMangaRequest(page: Int): Request {
val query = buildQuery {
"""
query (%limit: Int) {
topTitles(limit: %limit) {
name
slug
publisher_slug
logo { url }
}
}
""".trimIndent()
}
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("limit", POPULAR_LIMIT)
}
}
val body = payload.toString().toRequestBody(JSON_MEDIA_TYPE)
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_JSON)
.add("Content-Length", body.contentLength().toString())
.add("Content-Type", body.contentType().toString())
.build()
return POST(GRAPHQL_URL, newHeaders, body)
}
override fun popularMangaParse(response: Response): MangasPage {
val result = json.parseToJsonElement(response.body!!.string()).jsonObject
val comicList = result["data"]!!.jsonObject["topTitles"]!!
.let { json.decodeFromJsonElement<List<GraphiteComic>>(it) }
.map(::genericComicBookFromObject)
return MangasPage(comicList, hasNextPage = false)
}
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Not used")
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val searchUrl = "$baseUrl/api/title/search".toHttpUrl().newBuilder()
.addPathSegment(query)
.addQueryParameter("limit", POPULAR_LIMIT.toString())
.toString()
val refererUrl = "$baseUrl/s".toHttpUrl().newBuilder()
.addPathSegment(query)
.toString()
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_JSON)
.set("Referer", refererUrl)
.build()
return GET(searchUrl, newHeaders)
}
override fun searchMangaParse(response: Response): MangasPage {
val comicList = json.decodeFromString<List<GraphiteComic>>(response.body!!.string())
.map(::genericComicBookFromObject)
return MangasPage(comicList, hasNextPage = false)
}
// Workaround to allow "Open in browser" use the real URL.
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsApiRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
private fun mangaDetailsApiRequest(manga: SManga): Request {
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_JSON)
.set("Referer", baseUrl + manga.url)
.build()
val publisherSlug = manga.url
.substringAfter("/title/")
.substringBefore("/")
val comicSlug = manga.url.substringAfterLast("/")
val apiUrl = "$baseUrl/api/title/find/null/".toHttpUrl().newBuilder()
.addQueryParameter("publisher_slug", publisherSlug)
.addQueryParameter("slug", comicSlug)
.toString()
return GET(apiUrl, newHeaders)
}
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val comic = json.decodeFromString<GraphiteComic>(response.body!!.string())
title = comic.name
author = comic.creator.joinToString(", ") { it.name }
description = comic.description
genre = comic.genres
.sortedBy { it.name }
.joinToString(", ") { it.name }
thumbnail_url = comic.logo?.url
}
override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga)
private fun issueListRequest(comicId: String, comicUrl: String): Request {
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_JSON)
.set("Referer", baseUrl + comicUrl)
.build()
return GET("$baseUrl/api/title/issues/$comicId", newHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
// Need to get the comic id first to fetch the issues.
val comic = json.decodeFromString<GraphiteComic>(response.body!!.string())
val comicUrl = "/title/${comic.publisherSlug}/${comic.slug}"
val issueRequest = issueListRequest(comic.id, comicUrl)
val issueResponse = client.newCall(issueRequest).execute()
val issues = json.decodeFromString<List<GraphiteIssue>>(issueResponse.body!!.string())
return issues
.sortedBy { issue -> issue.volumeNumber * 10 + issue.number }
.filter { issue -> issue.accessRule.isNullOrBlank() }
.map { issue -> chapterFromObject(issue, comic) }
.reversed()
}
private fun chapterFromObject(issue: GraphiteIssue, comic: GraphiteComic): SChapter =
SChapter.create().apply {
name = "${issue.number} - ${issue.name}"
scanlator = comic.publisher?.name
date_upload = issue.createdAt.toDate()
url = "/issue/${comic.publisherSlug}/${comic.slug}/${issue.slug}"
}
override fun pageListRequest(chapter: SChapter): Request {
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_JSON)
.set("Referer", baseUrl + chapter.url)
.build()
val urlPaths = chapter.url
.removePrefix("/issue/")
.split("/")
val apiUrl = "$baseUrl/api/issue/find/null/".toHttpUrl().newBuilder()
.addQueryParameter("publisher_slug", urlPaths[0])
.addQueryParameter("title_slug", urlPaths[1])
.addQueryParameter("slug", urlPaths[2])
.toString()
return GET(apiUrl, newHeaders)
}
override fun pageListParse(response: Response): List<Page> {
val issue = json.decodeFromString<GraphiteIssue>(response.body!!.string())
val issueUrl = "$baseUrl/issue/${issue.publisherSlug}/${issue.titleSlug}/${issue.slug}"
return issue.pages
.mapIndexed { i, page ->
Page(i, "$issueUrl/${i + 1}", "$baseUrl/api/page/image/${page.id}")
}
}
override fun imageUrlParse(response: Response): String = ""
override fun imageRequest(page: Page): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_IMAGE)
.add("Host", baseUrl.toHttpUrl().host)
.set("Referer", page.url)
.build()
return GET(page.imageUrl!!, newHeaders)
}
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
private fun String.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(substringBefore("T"))?.time }
.getOrNull() ?: 0L
}
companion object {
private const val ACCEPT_ALL = "*/*"
private const val ACCEPT_JSON = "application/json, text/plain, */*"
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
private const val GRAPHQL_URL = "https://graphitecomics.com/graphql"
private const val POPULAR_LIMIT = 50
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
}
}

View File

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.extension.en.graphitecomics
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GraphiteComic(
val creator: List<GraphitePerson> = emptyList(),
val description: String = "",
val genres: List<GraphiteGenre> = emptyList(),
@SerialName("objectId") val id: String = "",
val logo: GraphiteComicImage? = null,
val name: String = "",
val publisher: GraphitePublisher? = null,
@SerialName("publisher_slug") val publisherSlug: String = "",
val slug: String = ""
)
@Serializable
data class GraphiteComicImage(
val url: String = ""
)
@Serializable
data class GraphitePerson(
val name: String = ""
)
@Serializable
data class GraphiteGenre(
@SerialName("genreName") val name: String = ""
)
@Serializable
data class GraphitePublisher(
val name: String = ""
)
@Serializable
data class GraphiteIssue(
val accessRule: String? = "",
val createdAt: String = "",
val name: String = "",
val number: Int = -1,
val pages: List<GraphitePage> = emptyList(),
@SerialName("publisher_slug") val publisherSlug: String = "",
val slug: String = "",
@SerialName("title_slug") val titleSlug: String = "",
@SerialName("volume_number") val volumeNumber: Int = -1
)
@Serializable
data class GraphitePage(
@SerialName("objectId") val id: String = "",
val isEncrypted: Boolean = false
)