diff --git a/src/en/templescan/build.gradle b/src/en/templescan/build.gradle index 7370e18136..1bdfaaf4ef 100644 --- a/src/en/templescan/build.gradle +++ b/src/en/templescan/build.gradle @@ -1,9 +1,7 @@ ext { extName = 'Temple Scan' extClass = '.TempleScan' - themePkg = 'heancms' - baseUrl = 'https://templescan.net' - overrideVersionCode = 17 + extVersionCode = 43 isNsfw = true } diff --git a/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Dto.kt b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Dto.kt new file mode 100644 index 0000000000..470c24e48b --- /dev/null +++ b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Dto.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.extension.en.templescan + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class BrowseSeries( + @SerialName("series_slug") private val slug: String, + val title: String, + @SerialName("alternative_names") val alternativeNames: String? = null, + private val thumbnail: String? = null, + val status: String? = null, + @SerialName("update_chapter") private val updatedAt: String? = null, + @SerialName("created_at") private val createdAt: String? = null, + @SerialName("total_views") val views: Long = 0, +) { + val updated: Long by lazy { + dateFormat.tryParse(updatedAt) + } + + val created: Long by lazy { + dateFormat.tryParse(createdAt) + } + + fun toSManga() = SManga.create().apply { + url = "/comic/$slug" + title = this@BrowseSeries.title + thumbnail_url = thumbnail + } +} + +@Serializable +class SeriesDetails( + @SerialName("series_slug") val slug: String, + val title: String, + val thumbnail: String? = null, + val author: String? = null, + val studio: String? = null, + @SerialName("release_year") val year: String? = null, + @SerialName("alternative_names") val alternativeNames: String? = null, + val adult: Boolean = false, + val badge: String? = null, + val status: String? = null, +) + +@Serializable +class ChapterList( + @SerialName("Season") val seasons: List, +) { + @Serializable + class Chapters( + @SerialName("Chapter") val chapters: List, + ) { + @Serializable + class Chapter( + @SerialName("chapter_name") val name: String, + @SerialName("chapter_title") val title: String? = null, + @SerialName("chapter_slug") val slug: String, + val price: Int, + @SerialName("created_at") private val createdAt: String? = null, + ) { + val created: Long by lazy { + dateFormat.tryParse(createdAt) + } + } + } +} + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) + +private fun SimpleDateFormat.tryParse(date: String?): Long { + date ?: return 0L + + return try { + parse(date)?.time ?: 0L + } catch (_: ParseException) { + 0L + } +} diff --git a/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Filters.kt b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Filters.kt new file mode 100644 index 0000000000..767c53693d --- /dev/null +++ b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/Filters.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.extension.en.templescan + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +abstract class SelectFilter( + name: String, + private val options: List>, + defaultValue: String? = null, +) : Filter.Select( + name, + options.map { it.first }.toTypedArray(), + options.indexOfFirst { it.second == defaultValue }.coerceAtLeast(0), +) { + val selected get() = options[state].second.takeUnless { it.isBlank() } +} + +class StatusFilter : SelectFilter( + "Status", + listOf( + "", + "Ongoing", + "Hiatus", + "Completed", + "Canceled", + "Dropped", + ).map { it to it }, +) + +class OrderFilter(default: String? = null) : SelectFilter( + "Order by", + listOf( + "Update Chapter" to "updated", + "Created At" to "created", + "Trending" to "views", + ), + default, +) { + companion object { + val POPULAR = FilterList(OrderFilter("views")) + val LATEST = FilterList(OrderFilter("updated")) + } +} + +fun getFilters() = FilterList( + StatusFilter(), + OrderFilter(), +) diff --git a/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/TempleScan.kt b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/TempleScan.kt index 4e8e5ef9f3..a9d8c43947 100644 --- a/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/TempleScan.kt +++ b/src/en/templescan/src/eu/kanade/tachiyomi/extension/en/templescan/TempleScan.kt @@ -1,22 +1,225 @@ package eu.kanade.tachiyomi.extension.en.templescan -import eu.kanade.tachiyomi.multisrc.heancms.HeanCms +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +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.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import kotlin.math.min -class TempleScan : HeanCms( - "Temple Scan", - "https://templescan.net", - "en", - apiUrl = "https://templescan.net/apiv1", -) { +class TempleScan : HttpSource() { + + override val name = "Temple Scan" + + override val lang = "en" + + override val baseUrl = "https://templescan.net" + + override val supportsLatest = true override val versionId = 3 - override val client = super.client.newBuilder() + override fun headersBuilder() = super.headersBuilder() + .set("referer", "$baseUrl/") + .set("origin", baseUrl) + + override val client = network.cloudflareClient.newBuilder() .rateLimit(1) .build() - override val mangaSubDirectory = "comic" + private val json: Json by injectLazy() + + override fun fetchPopularManga(page: Int): Observable { + return fetchSearchManga(page, "", OrderFilter.POPULAR) + } + + override fun fetchLatestUpdates(page: Int): Observable { + return fetchSearchManga(page, "", OrderFilter.LATEST) + } + + private lateinit var seriesCache: List + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (page == 1) { + client.newCall(searchMangaRequest(page, query, filters)) + .execute() + .use(::parseSearchResponse) + } + + return Observable.just(parseDirectory(page, query, filters)) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + return GET("$baseUrl/comics", headers) + } + + private fun parseSearchResponse(response: Response) { + val document = response.asJsoup() + val script = document.selectFirst("script:containsData(allComics)")!! + .data().unescape() + + with(script) { + val raw = substringAfter("""allComics":""") + .substringBeforeLast("}]]") + + seriesCache = raw.parseAs() + } + } + + private fun parseDirectory(page: Int, query: String, filters: FilterList): MangasPage { + val status = filters.get()?.selected + val mangaList = seriesCache.filter { series -> + + val queryFilter = query.isBlank() || + series.title.contains(query, ignoreCase = true) || + series.alternativeNames?.contains(query, ignoreCase = true) == true + + val statusFilter = status == null || series.status == status - override val enableLogin = true + queryFilter && statusFilter + }.let { + val order = filters.get()?.selected + + when (order) { + "updated" -> it.sortedByDescending { series -> series.updated } + "created" -> it.sortedByDescending { series -> series.created } + "views" -> it.sortedByDescending { series -> series.views } + else -> it + } + } + + return MangasPage( + mangas = mangaList.subList((page - 1) * 20, min(page * 20, mangaList.size)) + .map { it.toSManga() }, + hasNextPage = page * 20 < mangaList.size, + ) + } + + override fun getFilterList() = getFilters() + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + val details = DETAILS_REGEX.find(document.body().outerHtml())!!.groupValues[1] + .unescape() + .parseAs() + + val tags = mutableListOf() + + return SManga.create().apply { + url = "/comic/${details.slug}" + title = details.title + thumbnail_url = details.thumbnail + status = when (details.status) { + "Ongoing" -> SManga.ONGOING + "Hiatus" -> SManga.ON_HIATUS + "Completed" -> SManga.COMPLETED + "Canceled" -> SManga.CANCELLED + "Dropped" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + author = details.author + artist = details.studio + description = buildString { + document.selectFirst("div:has(> p:contains(description))")?.run { + selectFirst("p:contains(description)")?.remove() + selectFirst("div.mt-7:contains(Additional)")?.remove() + selectFirst("div.mt-7:contains(tag)")?.also { + tags += it.select("div.flex > p[class^=bg]").eachText() + }?.remove() + selectFirst("p:contains(tag), p:contains(genre)")?.let { + tags += it.text().substringAfter(":") + .split(",") + .map(String::trim) + // sometimes description

have the tag/genre, instead of it being separate + val tmp = clone() + tmp.selectFirst("p:contains(tag), p:contains(genre)") + ?.remove() + if (tmp.text().isNotBlank()) { + it.remove() + } + } + + this@buildString.append(wholeText().trim()) + } + + if (!details.alternativeNames.isNullOrBlank()) { + if (isNotBlank()) { + append("\n\n") + } + append("Alternative Name: ", details.alternativeNames, "\n") + } + } + genre = buildList { + add(details.badge) + add(details.year) + if (details.adult) { + add("Adult") + } + addAll(tags.distinct()) + }.filterNotNull().joinToString() + } + } + + override fun chapterListParse(response: Response): List { + val chapters = DETAILS_REGEX.find(response.body.string())!!.groupValues[1] + .unescape() + .parseAs() + val mangaSlug = response.request.url.pathSegments.last() + + return chapters.seasons.flatMap { season -> + season.chapters.filter { + it.price == 0 + }.map { chapter -> + SChapter.create().apply { + url = "/comic/$mangaSlug/${chapter.slug}" + name = buildString { + append(chapter.name) + if (!chapter.title.isNullOrBlank()) { + append(": ", chapter.title) + } + } + date_upload = chapter.created + } + } + } + } + + override fun pageListParse(response: Response): List { + return response.asJsoup().select("img[alt^=chapter]").mapIndexed { idx, img -> + Page(idx, imageUrl = img.absUrl("src")) + } + } + + private fun String.unescape(): String { + return UNESCAPE_REGEX.replace(this, "$1") + } + + private inline fun String.parseAs(): T { + return json.decodeFromString(this) + } + + private inline fun > FilterList.get(): T? { + return filterIsInstance().firstOrNull() + } + + override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException() + override fun popularMangaParse(response: Response) = throw UnsupportedOperationException() + override fun searchMangaParse(response: Response) = throw UnsupportedOperationException() + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException() + override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException() + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() } + +private val UNESCAPE_REGEX = """\\(.)""".toRegex() +private val DETAILS_REGEX = Regex("""info\\":(\{.*\}).*userIsFollowed""")