Add Clown Corps comic source (#1808)
* Add ClownCorps comic source * Apply suggestions from code review Thank you very much! Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Only loop through the present pages Don't just loop forever until a 404 is returned * Disable reduntant sorting code * Add date to chapters * Apply suggestions from code review Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Remove commented snippet Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Move vars to companion obj & inline description * Un-move some constants & Use Observable.just * Extract SManga creation to separate function And use where necessary * Omit unnecessary function call Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com> * Add caching I've tried a great many different ways of caching today, and as far as I can reason with my fried brain, I think this one now works pretty well. I shall continue testing it on my phone. * Change SerializableChapter implementation Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com> * Code cleanliness Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com> * Optimise requests for page 1 away * Explicitly sort chapters by upload date * Move other preference options into separate functions * Assume response document always contains what we're asking And throw a runtime exception if it doesn't, so the problem can be noticed and fixed. --------- Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
This commit is contained in:
parent
16bcd6bbd9
commit
0a0ff7c1ac
|
@ -0,0 +1,11 @@
|
|||
ext {
|
||||
extName = 'Clown Corps'
|
||||
extClass = '.ClownCorps'
|
||||
extVersionCode = 1
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:textinterceptor'))
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
|
@ -0,0 +1,245 @@
|
|||
package eu.kanade.tachiyomi.extension.en.clowncorps
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor
|
||||
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
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 eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class ClownCorps : ConfigurableSource, HttpSource() {
|
||||
override val baseUrl = "https://clowncorps.net"
|
||||
override val lang = "en"
|
||||
override val name = "Clown Corps"
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.addInterceptor(TextInterceptor())
|
||||
.build()
|
||||
|
||||
private fun getManga() = SManga.create().apply {
|
||||
title = name
|
||||
artist = CREATOR
|
||||
author = CREATOR
|
||||
status = SManga.ONGOING
|
||||
initialized = true
|
||||
// Image and description from: https://clowncorps.net/about/
|
||||
thumbnail_url = "$baseUrl/wp-content/uploads/2022/11/clowns41.jpg"
|
||||
description = "Clown Corps is a comic about crime-fighting clowns.\n" +
|
||||
"It's pronounced \"core.\" Like marine corps."
|
||||
url = "/comic"
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> =
|
||||
Observable.just(MangasPage(listOf(getManga()), hasNextPage = false))
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
fetchPopularManga(page)
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||
Observable.just(getManga())
|
||||
|
||||
@Serializable
|
||||
class SerializableChapter(val fullLink: String, val name: String, val dateUpload: Long) {
|
||||
override fun hashCode() = fullLink.hashCode()
|
||||
override fun equals(other: Any?) =
|
||||
other is SerializableChapter && fullLink == other.fullLink
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
// The total number of webpages with chapters on them
|
||||
val document = response.asJsoup()
|
||||
val currentPageIndicator = document.select("#paginav li.paginav-pages").text()
|
||||
val totalWebpageCount = currentPageIndicator.split(" ").last().toInt()
|
||||
|
||||
val allChapters = getChaptersFromCache().toMutableSet()
|
||||
// Fetch all the chapters from the website until we reached where the cache left off
|
||||
for (webpageIndex in 1..totalWebpageCount) {
|
||||
val pageDoc = if (webpageIndex == 1) document else fetchChapterWebpage(webpageIndex)
|
||||
val anyChaptersWereAdded = allChapters.addAll(extractChapters(pageDoc))
|
||||
if (!anyChaptersWereAdded) break // No new chapters were added from this webpage, so we're done
|
||||
}
|
||||
|
||||
// Save the chapters to cache
|
||||
val fullJsonString = Json.encodeToString(allChapters)
|
||||
setChapterCache(fullJsonString)
|
||||
|
||||
// Convert the serializable chapters to SChapters
|
||||
return allChapters
|
||||
.sortedByDescending { it.dateUpload }
|
||||
.map { chapter ->
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(chapter.fullLink)
|
||||
name = chapter.name
|
||||
date_upload = chapter.dateUpload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChaptersFromCache(): Set<SerializableChapter> {
|
||||
val cachedChaps = getChapterCache() ?: return emptySet()
|
||||
return Json.decodeFromString(cachedChaps)
|
||||
}
|
||||
|
||||
private fun fetchChapterWebpage(webpageIndex: Int): Document {
|
||||
val url = "$baseUrl/comic/page/$webpageIndex/"
|
||||
return client.newCall(GET(url, headers)).execute().asJsoup()
|
||||
}
|
||||
|
||||
private fun extractChapters(document: Document): List<SerializableChapter> {
|
||||
val comics = document.select(".comic")
|
||||
return comics.map {
|
||||
val link = it.selectFirst(".post-title a")!!.attr("href")
|
||||
val title = it.selectFirst(".post-title a")!!.text()
|
||||
val postDate = it.selectFirst(".post-date")!!.text()
|
||||
val postTime = it.selectFirst(".post-time")!!.text()
|
||||
val date = parseDate("$postDate $postTime")
|
||||
SerializableChapter(link, title, date)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDate(dateStr: String): Long {
|
||||
return try {
|
||||
dateFormat.parse(dateStr)!!.time
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFormat by lazy {
|
||||
SimpleDateFormat("MMMM dd, yyyy hh:mm aa", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val doc = response.asJsoup()
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
val image = doc.selectFirst("#comic img") ?: return pages
|
||||
|
||||
val url = image.attr("src")
|
||||
pages.add(Page(0, "", url))
|
||||
|
||||
if (getShowAuthorsNotesPref()) {
|
||||
val title = image.attr("title")
|
||||
|
||||
// Ignore chapters that don't really have author's notes
|
||||
val ignoreRegex = Regex("""^chapter \d+ page \d+$""", RegexOption.IGNORE_CASE)
|
||||
if (ignoreRegex.matches(title)) return pages
|
||||
|
||||
val localURL = TextInterceptorHelper.createUrl("Author's Notes from $CREATOR", title)
|
||||
val textPage = Page(pages.size, "", localURL)
|
||||
pages.add(textPage)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaParse(response: Response) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaParse(response: Response) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private fun getShowAuthorsNotesPref() =
|
||||
preferences.getBoolean(SETTING_KEY_SHOW_AUTHORS_NOTES, false)
|
||||
|
||||
private fun getChapterCache() =
|
||||
preferences.getString(CACHE_KEY_CHAPTERS, null)
|
||||
|
||||
private fun setChapterCache(json: String) =
|
||||
preferences.edit().putString(CACHE_KEY_CHAPTERS, json).apply()
|
||||
|
||||
private fun clearChapterCache() =
|
||||
preferences.edit().remove(CACHE_KEY_CHAPTERS).apply()
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val authorsNotesPref = SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SETTING_KEY_SHOW_AUTHORS_NOTES
|
||||
title = "Show author's notes"
|
||||
summary =
|
||||
"Enable to see the author's notes at the end of chapters (if they're there)."
|
||||
setDefaultValue(false)
|
||||
}
|
||||
screen.addPreference(authorsNotesPref)
|
||||
|
||||
// I couldn't find a way to create a simple button, so here's a workaround that uses
|
||||
// a MultiSelectListPreference with a single option as a kind of confirmation window.
|
||||
val clearCachePref = MultiSelectListPreference(screen.context).apply {
|
||||
key = SETTING_KEY_CLEAR_CHAPTER_CACHE
|
||||
title = "Clear chapter cache"
|
||||
summary = "Clears the chapter cache, forcing a full re-fetch from the website."
|
||||
dialogTitle = "Are you sure you want to clear the chapter cache?"
|
||||
entries = arrayOf("Yes, I'm sure")
|
||||
entryValues = arrayOf(VALUE_CONFIRM)
|
||||
setDefaultValue(emptySet<String>())
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val checkValue = newValue as Set<*>
|
||||
if (checkValue.contains(VALUE_CONFIRM)) {
|
||||
clearChapterCache()
|
||||
Toast.makeText(screen.context, "Cleared chapter cache", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
false // Don't actually save the "yes"
|
||||
}
|
||||
}
|
||||
screen.addPreference(clearCachePref)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CREATOR = "Joe Chouinard"
|
||||
|
||||
private const val SETTING_KEY_SHOW_AUTHORS_NOTES = "showAuthorsNotes"
|
||||
|
||||
private const val CACHE_KEY_CHAPTERS = "chaptersCache"
|
||||
|
||||
private const val SETTING_KEY_CLEAR_CHAPTER_CACHE = "clearChapterCache"
|
||||
private const val VALUE_CONFIRM = "yes"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue