diff --git a/src/en/roliascan/AndroidManifest.xml b/src/en/roliascan/AndroidManifest.xml new file mode 100644 index 000000000..a2d7c72e6 --- /dev/null +++ b/src/en/roliascan/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/en/roliascan/build.gradle b/src/en/roliascan/build.gradle new file mode 100644 index 000000000..6011be1bf --- /dev/null +++ b/src/en/roliascan/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Rolia Scan' + extClass = '.RoliaScan' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/roliascan/res/mipmap-hdpi/ic_launcher.png b/src/en/roliascan/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e3e81dae1 Binary files /dev/null and b/src/en/roliascan/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/roliascan/res/mipmap-mdpi/ic_launcher.png b/src/en/roliascan/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..6f033c558 Binary files /dev/null and b/src/en/roliascan/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/roliascan/res/mipmap-xhdpi/ic_launcher.png b/src/en/roliascan/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..3e2940cfb Binary files /dev/null and b/src/en/roliascan/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/roliascan/res/mipmap-xxhdpi/ic_launcher.png b/src/en/roliascan/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..3fd184c3f Binary files /dev/null and b/src/en/roliascan/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/roliascan/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/roliascan/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..43290b88b Binary files /dev/null and b/src/en/roliascan/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/roliascan/src/eu/kanade/tachiyomi/extension/en/roliascan/RoliaScan.kt b/src/en/roliascan/src/eu/kanade/tachiyomi/extension/en/roliascan/RoliaScan.kt new file mode 100644 index 000000000..0e9c1e6c5 --- /dev/null +++ b/src/en/roliascan/src/eu/kanade/tachiyomi/extension/en/roliascan/RoliaScan.kt @@ -0,0 +1,323 @@ +package eu.kanade.tachiyomi.extension.en.roliascan + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import uy.kohesive.injekt.injectLazy + +// Theme: AnimaCEWP +class RoliaScan : ParsedHttpSource() { + + override val name = "Rolia Scan" + + override val baseUrl = "https://roliascan.com" + + override val lang = "en" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(3) + .build() + + // ======================== Popular ====================================== + private val popularFilter by lazy { + FilterList(SelectionList("", listOf(ratingList.maxBy { it.value }))) + } + + override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", popularFilter) + + override fun popularMangaSelector() = searchMangaSelector() + + override fun popularMangaNextPageSelector() = searchMangaNextPageSelector() + + override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element) + + override fun popularMangaParse(response: Response): MangasPage { + if (genreList.isEmpty()) { + getFilters(response) + } + return super.popularMangaParse(response) + } + + // ======================== Latest ======================================= + + private val latestFilter = FilterList( + SelectionList("", listOf(Option("", "update_oldest", query = "_sort_posts"))), + ) + + override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", latestFilter) + + override fun latestUpdatesSelector() = searchMangaSelector() + + override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector() + + override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element) + + // ======================== Search ======================================= + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/manga".toHttpUrl().newBuilder() + + if (query.isNotBlank()) { + url.addQueryParameter("_post_type_search_box", query) + } + + filters.forEach { filter -> + when (filter) { + is SelectionList -> { + val selected = filter.selected() + if (selected.value.isBlank()) { + return@forEach + } + url.addQueryParameter(selected.query, selected.value) + } + is GenreList -> { + val genres = filter.state + .filter { it.state } + .joinToString(",") { it.id } + + if (genres.isBlank()) { + return@forEach + } + + url.addQueryParameter("_genres", genres) + } + else -> {} + } + } + + return GET(url.build(), headers) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(PREFIX_SEARCH)) { + val slug = query.substringAfter(PREFIX_SEARCH) + return fetchMangaDetails(SManga.create().apply { url = "/manga/$slug" }) + .map { manga -> MangasPage(listOf(manga), false) } + } + return super.fetchSearchManga(page, query, filters) + } + + override fun searchMangaSelector() = "div.post" + + override fun searchMangaNextPageSelector() = null + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + thumbnail_url = element.selectFirst("img")?.absUrl("src") + element.selectFirst("h6 a")!!.let { + title = it.text() + setUrlWithoutDomain(it.absUrl("href")) + } + } + + // ======================== Details ====================================== + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.selectFirst("h1")!!.text() + thumbnail_url = document + .selectFirst("div.post-type-single-column img.wp-post-image") + ?.absUrl("src") + description = document + .select("div.card-body:has(h5:contains(Synopsis)) p") + .filter { p -> p.text().isNotBlank() } + .joinToString("\n") { it.text() } + + genre = document.select("a[href*=genres]") + .joinToString { it.text() } + + document.selectFirst("tr:has(th:contains(Status)) > td")?.text()?.let { + status = when { + it.contains("publishing", true) -> SManga.ONGOING + it.contains("completed", true) -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + setUrlWithoutDomain(document.location()) + } + + // ======================== Chapters ===================================== + + override fun fetchChapterList(manga: SManga): Observable> { + val chapters = mutableListOf() + val url = "$baseUrl/wp-admin/admin-ajax.php" + + val document = client.newCall(chapterListRequest(manga)).execute() + .asJsoup() + + val postId = document + .select("input[name=current_page_id]") + .attr("value") + + chapters += document.select(chapterListSelector()).map(::chapterFromElement) + val step = 20 + var offset = step + + do { + val formBuilder = FormBody.Builder() + .add("action", "load_more_chapters") + .add("post_id", postId) + .add("offset", offset.toString()) + + val chapterPage = client.newCall(POST(url, headers, formBuilder.build())).execute().asJsoup() + .select(chapterListSelector()) + .map(::chapterFromElement) + + chapters += chapterPage + offset += step + } while (chapterPage.isNotEmpty()) + + return Observable.just(chapters) + } + + override fun chapterListRequest(manga: SManga): Request { + val url = super.chapterListRequest(manga).url.newBuilder() + .addPathSegment("chapterlist") + .build() + return GET(url, headers) + } + + override fun chapterListSelector() = "a.seenchapter" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + name = element.text() + setUrlWithoutDomain(element.absUrl("href")) + } + + // ======================== Pages ======================================== + + override fun pageListParse(document: Document): List { + return document.select(".manga-child-the-content img").mapIndexed { index, element -> + Page(index, imageUrl = element.absUrl("src")) + } + } + + override fun imageUrlParse(document: Document) = "" + + // ======================== Filters ====================================== + + private var genreList = emptyList() + + private val ratingList = listOf( + Option("Any"), + Option("★★★★★ (5)", "5"), + Option("★★★★☆ (4)", "4"), + Option("★★★☆☆ (3)", "3"), + Option("★★☆☆☆ (2)", "2"), + Option("★☆☆☆☆ (1)", "1"), + ).map { it.copy(query = "_rating") } + + private var optionList = emptyList>>() + + override fun getFilterList(): FilterList { + val filters = mutableListOf>( + SelectionList("Rating", ratingList), + ) + + filters += optionList.flatMap { + listOf( + Filter.Separator(), + SelectionList(it.first, it.second), + ) + } + + filters += if (genreList.isNotEmpty()) { + listOf( + Filter.Separator(), + GenreList(title = "Genres", genres = genreList), + ) + } else { + listOf( + Filter.Separator(), + Filter.Header("Press 'Reset' to attempt to show the genres, sort and years filters"), + ) + } + return FilterList(filters) + } + + private fun getFilters(response: Response) { + val document = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string()) + + val script = document.selectFirst("script:containsData(FWP_JSON)")?.data() + ?: return + + script.getDocumentFragmentFilter(buildRegex("genres"))?.let { + genreList = it.select(".facetwp-checkbox").map { element -> + Genre( + name = element.selectFirst(".facetwp-display-value")!!.text(), + id = element.attr("data-value"), + ) + } + } + + val queries = listOf( + "Sort" to "sort_posts", + "Year" to "movies_series_year", + "Author" to "movies_series_staff", + ) + + optionList = queries.map { + it.first to getOptionList(buildRegex(it.second), script) + } + } + + private fun getOptionList(pattern: Regex, content: String, cssQuery: String = "option"): List