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:
altaccosc 2024-03-17 19:05:45 +01:00 committed by Draff
parent 16bcd6bbd9
commit 0a0ff7c1ac
7 changed files with 256 additions and 0 deletions

View File

@ -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

View File

@ -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"
}
}