Refactor SS extension due to website redesign (#7655)

* Refactor SS extension due to website redesign.

* Remove compatibility with old URLs since id changed.
This commit is contained in:
Alessandro Jean 2021-06-14 18:35:50 -03:00 committed by GitHub
parent 00547b5413
commit d002d31c0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 412 additions and 87 deletions

View File

@ -1,11 +1,12 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext { ext {
extName = 'Saikai Scan' extName = 'Saikai Scan'
pkgNameSuffix = 'pt.saikaiscan' pkgNameSuffix = 'pt.saikaiscan'
extClass = '.SaikaiScan' extClass = '.SaikaiScan'
extVersionCode = 5 extVersionCode = 6
libVersion = '1.2' libVersion = '1.2'
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -2,25 +2,30 @@ package eu.kanade.tachiyomi.extension.pt.saikaiscan
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.Jsoup
import org.jsoup.nodes.Element import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class SaikaiScan : ParsedHttpSource() { class SaikaiScan : HttpSource() {
// Hardcode the id because the language wasn't specific.
override val id: Long = 2686610366990303664
override val name = "Saikai Scan" override val name = "Saikai Scan"
@ -30,121 +35,377 @@ class SaikaiScan : ParsedHttpSource() {
override val supportsLatest = true override val supportsLatest = true
override val client: OkHttpClient = network.client.newBuilder() override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(RateLimitInterceptor(1, 1, TimeUnit.SECONDS)) .addInterceptor(RateLimitInterceptor(1, 1, TimeUnit.SECONDS))
.build() .build()
private val json: Json by injectLazy()
override fun headersBuilder(): Headers.Builder = Headers.Builder() override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("User-Agent", USER_AGENT)
.add("Origin", baseUrl) .add("Origin", baseUrl)
.add("Referer", baseUrl) .add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) override fun popularMangaRequest(page: Int): Request {
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
override fun popularMangaSelector(): String = "div#menu ul li.has_submenu:eq(3) li a" val apiEndpointUrl = "$API_URL/api/stories".toHttpUrl().newBuilder()
.addQueryParameter("format", COMIC_FORMAT_ID)
.addQueryParameter("sortProperty", "pageviews")
.addQueryParameter("sortDirection", "desc")
.addQueryParameter("page", page.toString())
.addQueryParameter("per_page", PER_PAGE)
.addQueryParameter("relationships", "language,type,format")
.toString()
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { return GET(apiEndpointUrl, apiHeaders)
title = element.text().substringBeforeLast("(")
url = element.attr("href")
} }
override fun popularMangaNextPageSelector(): String? = null override fun popularMangaParse(response: Response): MangasPage {
val result = json.decodeFromString<SaikaiScanPaginatedStoriesDto>(response.body!!.string())
override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) val mangaList = result.data!!.map(::popularMangaFromObject)
val hasNextPage = result.meta!!.currentPage < result.meta.lastPage
override fun latestUpdatesSelector(): String = "ul.manhuas li.manhua-item" return MangasPage(mangaList, hasNextPage)
override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
val image = element.select("div.image.lazyload")
val name = element.select("h3")
title = name.text().substringBeforeLast("(")
thumbnail_url = baseUrl + image.attr("data-src")
url = image.select("a").attr("href")
} }
override fun latestUpdatesNextPageSelector(): String? = null private fun popularMangaFromObject(obj: SaikaiScanStoryDto): SManga = SManga.create().apply {
title = obj.title
thumbnail_url = "$IMAGE_SERVER_URL/${obj.image}"
url = "/comics/${obj.slug}"
}
override fun latestUpdatesRequest(page: Int): Request {
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val apiEndpointUrl = "$API_URL/api/lancamentos".toHttpUrl().newBuilder()
.addQueryParameter("format", COMIC_FORMAT_ID)
.addQueryParameter("page", page.toString())
.addQueryParameter("per_page", PER_PAGE)
.addQueryParameter("relationships", "language,type,format,latestReleases.separator")
.toString()
return GET(apiEndpointUrl, apiHeaders)
}
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/busca".toHttpUrlOrNull()!!.newBuilder() val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val apiEndpointUrl = "$API_URL/api/stories".toHttpUrl().newBuilder()
.addQueryParameter("format", COMIC_FORMAT_ID)
.addQueryParameter("q", query) .addQueryParameter("q", query)
.addQueryParameter("sortProperty", "pageViews")
.addQueryParameter("sortDirection", "desc")
.addQueryParameter("page", page.toString())
.addQueryParameter("per_page", PER_PAGE)
.addQueryParameter("relationships", "language,type,format")
return GET(url.toString(), headers) filters.forEach { filter ->
} when (filter) {
is GenreFilter -> {
val genresParameter = filter.state
.filter { it.state }
.joinToString(",") { it.id.toString() }
apiEndpointUrl.addQueryParameter("genres", genresParameter)
}
override fun searchMangaParse(response: Response): MangasPage { is CountryFilter -> {
val results = super.searchMangaParse(response) if (filter.state > 0) {
val manhuas = results.mangas.filter { it.url.contains("/manhuas/") } apiEndpointUrl.addQueryParameter("country", filter.selected.id.toString())
}
}
return MangasPage(manhuas, results.hasNextPage) is StatusFilter -> {
} if (filter.state > 0) {
apiEndpointUrl.addQueryParameter("status", filter.selected.id.toString())
}
}
override fun searchMangaSelector(): String = "div#news-content ul li" is SortByFilter -> {
val sortProperty = filter.sortProperties[filter.state!!.index]
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { val sortDirection = if (filter.state!!.ascending) "asc" else "desc"
val image = element.select("div.image.lazyload") apiEndpointUrl.setQueryParameter("sortProperty", sortProperty.slug)
val name = element.select("h3") apiEndpointUrl.setQueryParameter("sortDirection", sortDirection)
}
title = name.text().substringBeforeLast("(") }
thumbnail_url = baseUrl + image.attr("data-src")
url = image.select("a").attr("href")
}
override fun searchMangaNextPageSelector(): String? = null
override fun mangaDetailsParse(document: Document): SManga {
val projectContent = document.select("div#project-content")
val name = projectContent.select("h2").first()
val cover = projectContent.select("div.cover img.lazyload")
val genres = projectContent.select("div.info:contains(Gênero:)")
val author = projectContent.select("div.info:contains(Autor:)")
val status = projectContent.select("div.info:contains(Status:)")
val summary = projectContent.select("div.summary-text")
return SManga.create().apply {
title = name.text()
thumbnail_url = baseUrl + cover.attr("data-src")
genre = removeLabel(genres.text())
this.author = removeLabel(author.text())
artist = removeLabel(author.text())
this.status = parseStatus(removeLabel(status.text()))
description = summary.text()
} }
return GET(apiEndpointUrl.toString(), apiHeaders)
} }
private fun parseStatus(status: String) = when { override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
status.contains("Completo") -> SManga.COMPLETED
status.contains("Em Tradução", true) -> SManga.ONGOING // Workaround to allow "Open in browser" use the real URL.
else -> SManga.UNKNOWN override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(storyDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
private fun storyDetailsRequest(manga: SManga): Request {
val storySlug = manga.url.substringAfterLast("/")
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val apiEndpointUrl = "$API_URL/api/stories".toHttpUrl().newBuilder()
.addQueryParameter("format", COMIC_FORMAT_ID)
.addQueryParameter("slug", storySlug)
.addQueryParameter("per_page", "1")
.addQueryParameter("relationships", "language,type,format,artists,status")
.toString()
return GET(apiEndpointUrl, apiHeaders)
}
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val result = json.decodeFromString<SaikaiScanPaginatedStoriesDto>(response.body!!.string())
val story = result.data!![0]
title = story.title
author = story.authors.joinToString { it.name }
artist = story.artists.joinToString { it.name }
thumbnail_url = "$IMAGE_SERVER_URL/${story.image}"
genre = story.genres.joinToString { it.name }
status = story.status!!.name.toStatus()
description = Jsoup.parse(story.synopsis)
.select("p")
.joinToString("\n\n") { it.text() }
}
override fun chapterListRequest(manga: SManga): Request {
val storySlug = manga.url.substringAfterLast("/")
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val apiEndpointUrl = "$API_URL/api/stories".toHttpUrl().newBuilder()
.addQueryParameter("format", COMIC_FORMAT_ID)
.addQueryParameter("slug", storySlug)
.addQueryParameter("per_page", "1")
.addQueryParameter("relationships", "releases")
.toString()
return GET(apiEndpointUrl, apiHeaders)
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
return super.chapterListParse(response).reversed() val result = json.decodeFromString<SaikaiScanPaginatedStoriesDto>(response.body!!.string())
val story = result.data!![0]
return story.releases
.filter { it.isActive == 1 }
.map { chapterFromObject(it, story.slug) }
.sortedByDescending { it.chapter_number }
} }
override fun chapterListSelector(): String = "div#project-content div.project-chapters div.chapters ul li a" private fun chapterFromObject(obj: SaikaiScanReleaseDto, storySlug: String): SChapter =
SChapter.create().apply {
name = "Capítulo ${obj.chapter}" +
(if (obj.title.isNullOrEmpty().not()) " - ${obj.title}" else "")
chapter_number = obj.chapter.toFloatOrNull() ?: -1f
date_upload = obj.publishedAt.substringBefore(" ").toDate()
scanlator = this@SaikaiScan.name
url = "/ler/comics/$storySlug/${obj.id}/${obj.slug}"
}
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { override fun pageListRequest(chapter: SChapter): Request {
scanlator = "Saikai Scan" val releaseId = chapter.url
chapter_number = CHAPTER_REGEX.find(element.text())?.groupValues?.get(1)?.toFloatOrNull() ?: -1f .substringBeforeLast("/")
name = element.text() .substringAfterLast("/")
url = element.attr("href")
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val apiEndpointUrl = "$API_URL/api/releases/$releaseId".toHttpUrl().newBuilder()
.addQueryParameter("relationships", "releaseImages")
.toString()
return GET(apiEndpointUrl, apiHeaders)
} }
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(response: Response): List<Page> {
val imagesBlock = document.select("div.manhua-slide div.images-block img.lazyload") val result = json.decodeFromString<SaikaiScanReleaseResultDto>(response.body!!.string())
return imagesBlock return result.data!!.releaseImages.mapIndexed { i, obj ->
.mapIndexed { i, el -> Page(i, "", el.absUrl("src")) } Page(i, "", "$IMAGE_SERVER_URL/${obj.image}")
}
} }
override fun imageUrlParse(document: Document): String = "" override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
private fun removeLabel(info: String) = info.substringAfter(":") override fun imageUrlParse(response: Response): String = ""
override fun imageRequest(page: Page): Request {
val imageHeaders = headersBuilder()
.add("Accept", ACCEPT_IMAGE)
.build()
return GET(page.imageUrl!!, imageHeaders)
}
private class Genre(title: String, val id: Int) : Filter.CheckBox(title)
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Gêneros", genres)
private data class Country(val name: String, val id: Int) {
override fun toString(): String = name
}
private open class EnhancedSelect<T>(name: String, values: Array<T>) : Filter.Select<T>(name, values) {
val selected: T
get() = values[state]
}
private class CountryFilter(countries: List<Country>) : EnhancedSelect<Country>(
"Nacionalidade",
countries.toTypedArray()
)
private data class Status(val name: String, val id: Int) {
override fun toString(): String = name
}
private class StatusFilter(statuses: List<Status>) : EnhancedSelect<Status>(
"Status",
statuses.toTypedArray()
)
private data class SortProperty(val name: String, val slug: String) {
override fun toString(): String = name
}
private class SortByFilter(val sortProperties: List<SortProperty>) : Filter.Sort(
"Ordenar por",
sortProperties.map { it.name }.toTypedArray(),
Selection(2, ascending = false)
)
// fetch('https://api.saikai.com.br/api/genres')
// .then(res => res.json())
// .then(res => console.log(res.data.map(g => `Genre("${g.name}", ${g.id})`).join(',\n')))
private fun getGenreList(): List<Genre> = listOf(
Genre("Ação", 1),
Genre("Adulto", 23),
Genre("Artes Marciais", 84),
Genre("Aventura", 2),
Genre("Comédia", 15),
Genre("Drama", 14),
Genre("Ecchi", 19),
Genre("Esportes", 42),
Genre("eSports", 25),
Genre("Fantasia", 3),
Genre("Ficção Cientifica", 16),
Genre("Histórico", 37),
Genre("Horror", 27),
Genre("Isekai", 52),
Genre("Josei", 40),
Genre("Luta", 68),
Genre("Magia", 11),
Genre("Militar", 76),
Genre("Mistério", 57),
Genre("MMORPG", 80),
Genre("Música", 82),
Genre("One-shot", 51),
Genre("Psicológico", 34),
Genre("Realidade Vitual", 18),
Genre("Reencarnação", 43),
Genre("Romance", 9),
Genre("RPG", 61),
Genre("Sci-fi", 58),
Genre("Seinen", 21),
Genre("Shoujo", 35),
Genre("Shounen", 26),
Genre("Slice of Life", 38),
Genre("Sobrenatural", 74),
Genre("Suspense", 63),
Genre("Tragédia", 22),
Genre("VRMMO", 17),
Genre("Wuxia", 6),
Genre("Xianxia", 7),
Genre("Xuanhuan", 48),
Genre("Yaoi", 41),
Genre("Yuri", 83)
)
// fetch('https://api.saikai.com.br/api/countries?hasStories=1')
// .then(res => res.json())
// .then(res => console.log(res.data.map(g => `Country("${g.name}", ${g.id})`).join(',\n')))
private fun getCountryList(): List<Country> = listOf(
Country("Todas", 0),
Country("Brasil", 32),
Country("China", 45),
Country("Coréia do Sul", 115),
Country("Espanha", 199),
Country("Estados Unidos da América", 1),
Country("Japão", 109),
Country("Portugal", 173)
)
// fetch('https://api.saikai.com.br/api/countries?hasStories=1')
// .then(res => res.json())
// .then(res => console.log(res.data.map(g => `Country("${g.name}", ${g.id})`).join(',\n')))
private fun getStatusList(): List<Status> = listOf(
Status("Todos", 0),
Status("Cancelado", 5),
Status("Concluído", 1),
Status("Dropado", 6),
Status("Em Andamento", 2),
Status("Hiato", 4),
Status("Pausado", 3)
)
private fun getSortProperties(): List<SortProperty> = listOf(
SortProperty("Título", "title"),
SortProperty("Quantidade de capítulos", "releases_count"),
SortProperty("Visualizações", "pageviews"),
SortProperty("Data de criação", "created_at")
)
override fun getFilterList(): FilterList = FilterList(
CountryFilter(getCountryList()),
StatusFilter(getStatusList()),
SortByFilter(getSortProperties()),
GenreFilter(getGenreList())
)
private fun String.toDate(): Long {
return try {
DATE_FORMATTER.parse(this)?.time ?: 0L
} catch (e: ParseException) {
0L
}
}
private fun String.toStatus(): Int = when (this) {
"Concluído" -> SManga.COMPLETED
"Em Andamento" -> SManga.ONGOING
else -> SManga.UNKNOWN
}
companion object { companion object {
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36" private const val ACCEPT_JSON = "application/json, text/plain, */*"
private val CHAPTER_REGEX = "Capítulo (\\d+)".toRegex()
private const val COMIC_FORMAT_ID = "2"
private const val PER_PAGE = "12"
private val DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd", Locale("pt", "BR"))
private const val API_URL = "https://api.saikai.com.br"
private const val IMAGE_SERVER_URL = "https://s3-alpha.saikai.com.br"
} }
} }

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.extension.pt.saikaiscan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SaikaiScanResultDto<T>(
val data: T? = null,
val meta: SaikaiScanMetaDto? = null
)
typealias SaikaiScanPaginatedStoriesDto = SaikaiScanResultDto<List<SaikaiScanStoryDto>>
typealias SaikaiScanReleaseResultDto = SaikaiScanResultDto<SaikaiScanReleaseDto>
@Serializable
data class SaikaiScanMetaDto(
@SerialName("current_page") val currentPage: Int,
@SerialName("last_page") val lastPage: Int
)
@Serializable
data class SaikaiScanStoryDto(
val artists: List<SaikaiScanPersonDto> = emptyList(),
val authors: List<SaikaiScanPersonDto> = emptyList(),
val genres: List<SaikaiScanGenreDto> = emptyList(),
val image: String,
val releases: List<SaikaiScanReleaseDto> = emptyList(),
val slug: String,
val status: SaikaiScanStatusDto? = null,
val synopsis: String,
val title: String
)
@Serializable
data class SaikaiScanPersonDto(
val name: String
)
@Serializable
data class SaikaiScanGenreDto(
val name: String
)
@Serializable
data class SaikaiScanStatusDto(
val name: String
)
@Serializable
data class SaikaiScanReleaseDto(
val chapter: String,
val id: Int,
@SerialName("is_active") val isActive: Int = 1,
@SerialName("published_at") val publishedAt: String,
@SerialName("release_images") val releaseImages: List<SaikaiScanReleaseImageDto> = emptyList(),
val slug: String,
val title: String? = ""
)
@Serializable
data class SaikaiScanReleaseImageDto(
val image: String
)