Add MangaPark, Mangago, Tapastic, nhentai, E-Hentai and Sen Manga sources (#36)
* Add MangaPark source * Add pagination to MangaPark source Enable HTTPS in MangaPark source * Add Mangago source * Add Tapastic source Fix UriSelectFilters returning incorrect default states * Add nhentai source * Fix tapastic source breaking on manga with square brackets in title * Fix issues found by j2ghz Fix tapastic source showing scheduled comics * Add E-Hentai source Bump Kotlin version for all extensions to 1.1.1 Minor code cleanup in nhentai source * Add Sen Manga source. Minor code cleanup. * Fix incorrect package name in Sen Manga source. * Fix incorrect Japanese language code on E-Hentai, nhentai and Sen Manga sources. * Bump Kotlin version to 1.1.2 * Code cleanup Fix a bug with thumbnails and URLs in E-Hentai that is currently not triggerable but may cause problems in the future * Code cleanup * Fix some minor incorrect spacing
This commit is contained in:
parent
5c1054caf5
commit
7929a768c4
@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.0.6'
|
ext.kotlin_version = '1.1.2'
|
||||||
repositories {
|
repositories {
|
||||||
jcenter()
|
jcenter()
|
||||||
}
|
}
|
||||||
|
18
src/all/ehentai/build.gradle
Normal file
18
src/all/ehentai/build.gradle
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: E-Hentai'
|
||||||
|
pkgNameSuffix = "all.ehentai"
|
||||||
|
extClass = '.EHJapanese; .EHEnglish; .EHChinese; .EHDutch; .EHFrench; .EHGerman; .EHHungarian; .EHItalian; .EHKorean; .EHPolish; .EHPolish; .EHPortuguese; .EHRussian; .EHSpanish; .EHThai; .EHVietnamese; .EHSpeechless; .EHOther'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
provided "com.google.code.gson:gson:2.8.0"
|
||||||
|
provided "com.github.salomonbrys.kotson:kotson:2.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,42 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Hentai languages
|
||||||
|
*/
|
||||||
|
class EHJapanese: EHentai("ja", "japanese")
|
||||||
|
class EHEnglish: EHentai("en", "english")
|
||||||
|
class EHChinese: EHentai("zh", "chinese")
|
||||||
|
class EHDutch: EHentai("nl", "dutch")
|
||||||
|
class EHFrench: EHentai("fr", "french")
|
||||||
|
class EHGerman: EHentai("de", "german")
|
||||||
|
class EHHungarian: EHentai("hu", "hungarian")
|
||||||
|
class EHItalian: EHentai("it", "italian")
|
||||||
|
class EHKorean: EHentai("ko", "korean")
|
||||||
|
class EHPolish: EHentai("pl", "polish")
|
||||||
|
class EHPortuguese: EHentai("pt", "portuguese")
|
||||||
|
class EHRussian: EHentai("ru", "russian")
|
||||||
|
class EHSpanish: EHentai("es", "spanish")
|
||||||
|
class EHThai: EHentai("th", "thai")
|
||||||
|
class EHVietnamese: EHentai("vi", "vietnamese")
|
||||||
|
class EHSpeechless: EHentai("none", "n/a")
|
||||||
|
class EHOther: EHentai("other", "other")
|
||||||
|
|
||||||
|
fun getAllEHentaiLanguages() = listOf(
|
||||||
|
EHJapanese(),
|
||||||
|
EHEnglish(),
|
||||||
|
EHChinese(),
|
||||||
|
EHDutch(),
|
||||||
|
EHFrench(),
|
||||||
|
EHGerman(),
|
||||||
|
EHHungarian(),
|
||||||
|
EHItalian(),
|
||||||
|
EHKorean(),
|
||||||
|
EHPolish(),
|
||||||
|
EHPortuguese(),
|
||||||
|
EHRussian(),
|
||||||
|
EHSpanish(),
|
||||||
|
EHThai(),
|
||||||
|
EHVietnamese(),
|
||||||
|
EHSpeechless(),
|
||||||
|
EHOther()
|
||||||
|
)
|
@ -0,0 +1,67 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Various utility methods used in the E-Hentai source
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return null if String is blank, otherwise returns the original String
|
||||||
|
* @returns null if the String is blank, otherwise returns the original String
|
||||||
|
*/
|
||||||
|
fun String?.nullIfBlank(): String? = if (isNullOrBlank())
|
||||||
|
null
|
||||||
|
else
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignores any exceptions thrown inside a block
|
||||||
|
*/
|
||||||
|
fun <T> ignore(expr: () -> T): T? {
|
||||||
|
return try {
|
||||||
|
expr()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use '+' to append Strings onto a StringBuilder
|
||||||
|
*/
|
||||||
|
operator fun StringBuilder.plusAssign(other: String) {
|
||||||
|
append(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts bytes into a human readable String
|
||||||
|
*/
|
||||||
|
fun humanReadableByteCount(bytes: Long, si: Boolean): String {
|
||||||
|
val unit = if (si) 1000 else 1024
|
||||||
|
if (bytes < unit) return bytes.toString() + " B"
|
||||||
|
val exp = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt()
|
||||||
|
val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
|
||||||
|
return String.format("%.1f %sB", bytes / Math.pow(unit.toDouble(), exp.toDouble()), pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val KB_FACTOR = 1000
|
||||||
|
private val KIB_FACTOR = 1024
|
||||||
|
private val MB_FACTOR = 1000 * KB_FACTOR
|
||||||
|
private val MIB_FACTOR = 1024 * KIB_FACTOR
|
||||||
|
private val GB_FACTOR = 1000 * MB_FACTOR
|
||||||
|
private val GIB_FACTOR = 1024 * MIB_FACTOR
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse human readable size Strings
|
||||||
|
*/
|
||||||
|
fun parseHumanReadableByteCount(arg0: String): Double? {
|
||||||
|
val spaceNdx = arg0.indexOf(" ")
|
||||||
|
val ret = arg0.substring(0 until spaceNdx).toDouble()
|
||||||
|
when (arg0.substring(spaceNdx + 1)) {
|
||||||
|
"GB" -> return ret * GB_FACTOR
|
||||||
|
"GiB" -> return ret * GIB_FACTOR
|
||||||
|
"MB" -> return ret * MB_FACTOR
|
||||||
|
"MiB" -> return ret * MIB_FACTOR
|
||||||
|
"KB" -> return ret * KB_FACTOR
|
||||||
|
"KiB" -> return ret * KIB_FACTOR
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
@ -0,0 +1,378 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
open class EHentai(override val lang: String, val ehLang: String) : HttpSource() {
|
||||||
|
|
||||||
|
override val name = "E-Hentai"
|
||||||
|
|
||||||
|
override val baseUrl = "https://e-hentai.org"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery list entry
|
||||||
|
* @param fav The favorite this gallery belongs to (currently unused)
|
||||||
|
* @param manga The manga object
|
||||||
|
*/
|
||||||
|
data class ParsedManga(val fav: String?, val manga: SManga)
|
||||||
|
|
||||||
|
fun extendedGenericMangaParse(doc: Document)
|
||||||
|
= with(doc) {
|
||||||
|
//Parse mangas
|
||||||
|
val parsedMangas = select(".gtr0,.gtr1").map {
|
||||||
|
ParsedManga(
|
||||||
|
fav = it.select(".itd .it3 > .i[id]").attr("title"),
|
||||||
|
manga = SManga.create().apply {
|
||||||
|
//Get title
|
||||||
|
it.select(".itd .it5 a").apply {
|
||||||
|
title = text()
|
||||||
|
setUrlWithoutDomain(addParam(attr("href"), "nw", "always"))
|
||||||
|
}
|
||||||
|
//Get image
|
||||||
|
it.select(".itd .it2").first().apply {
|
||||||
|
children().first()?.let {
|
||||||
|
thumbnail_url = it.attr("src")
|
||||||
|
} ?: text().split("~").apply {
|
||||||
|
thumbnail_url = "http://${this[1]}/${this[2]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
//Add to page if required
|
||||||
|
val hasNextPage = select("a[onclick=return false]").last()?.text() == ">"
|
||||||
|
Pair(parsedMangas, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a list of galleries
|
||||||
|
*/
|
||||||
|
fun genericMangaParse(response: Response)
|
||||||
|
= extendedGenericMangaParse(response.asJsoup()).let {
|
||||||
|
MangasPage(it.first.map { it.manga }, it.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
|
||||||
|
= Observable.just(listOf(SChapter.create().apply {
|
||||||
|
url = manga.url
|
||||||
|
name = "Chapter"
|
||||||
|
chapter_number = 1f
|
||||||
|
}))
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter)
|
||||||
|
= fetchChapterPage(chapter, "$baseUrl/${chapter.url}").map {
|
||||||
|
it.mapIndexed { i, s ->
|
||||||
|
Page(i, s)
|
||||||
|
}
|
||||||
|
}!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively fetch chapter pages
|
||||||
|
*/
|
||||||
|
private fun fetchChapterPage(chapter: SChapter, np: String,
|
||||||
|
pastUrls: List<String> = emptyList()): Observable<List<String>> {
|
||||||
|
val urls = pastUrls.toMutableList()
|
||||||
|
return chapterPageCall(np).flatMap {
|
||||||
|
val jsoup = it.asJsoup()
|
||||||
|
urls += parseChapterPage(jsoup)
|
||||||
|
nextPageUrl(jsoup)?.let {
|
||||||
|
fetchChapterPage(chapter, it, urls)
|
||||||
|
} ?: Observable.just(urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterPage(response: Element)
|
||||||
|
= with(response) {
|
||||||
|
select(".gdtm a").map {
|
||||||
|
Pair(it.child(0).attr("alt").toInt(), it.attr("href"))
|
||||||
|
}.sortedBy(Pair<Int, String>::first).map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterPageCall(np: String) = client.newCall(chapterPageRequest(np)).asObservableSuccess()
|
||||||
|
private fun chapterPageRequest(np: String) = exGet(np, null, headers)
|
||||||
|
|
||||||
|
private fun nextPageUrl(element: Element)
|
||||||
|
= element.select("a[onclick=return false]").last()?.let {
|
||||||
|
if (it.text() == ">") it.attr("href") else null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = latestUpdatesRequest(page)
|
||||||
|
//This source supports finding popular manga but will not respect language filters on the popular manga page!
|
||||||
|
//We currently display the latest updates instead until this is fixed
|
||||||
|
//override fun popularMangaRequest(page: Int) = exGet("$baseUrl/toplist.php?tl=15", page)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon()
|
||||||
|
uri.appendQueryParameter("f_search", query)
|
||||||
|
filters.forEach {
|
||||||
|
if (it is UriFilter) it.addToUri(uri)
|
||||||
|
}
|
||||||
|
return exGet(uri.toString(), page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = exGet(baseUrl, page)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response) = genericMangaParse(response)
|
||||||
|
override fun searchMangaParse(response: Response) = genericMangaParse(response)
|
||||||
|
override fun latestUpdatesParse(response: Response) = genericMangaParse(response)
|
||||||
|
|
||||||
|
fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cache: Boolean = true)
|
||||||
|
= GET(page?.let {
|
||||||
|
addParam(url, "page", (it - 1).toString())
|
||||||
|
} ?: url, additionalHeaders?.let {
|
||||||
|
val headers = headers.newBuilder()
|
||||||
|
it.toMultimap().forEach { (t, u) ->
|
||||||
|
u.forEach {
|
||||||
|
headers.add(t, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.build()
|
||||||
|
} ?: headers).let {
|
||||||
|
if (!cache)
|
||||||
|
it.newBuilder().cacheControl(CacheControl.FORCE_NETWORK).build()
|
||||||
|
else
|
||||||
|
it
|
||||||
|
}!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse gallery page to metadata model
|
||||||
|
*/
|
||||||
|
override fun mangaDetailsParse(response: Response) = with(response.asJsoup()) {
|
||||||
|
with(ExGalleryMetadata()) {
|
||||||
|
url = response.request().url().encodedPath()
|
||||||
|
title = select("#gn").text().nullIfBlank()?.trim()
|
||||||
|
|
||||||
|
altTitle = select("#gj").text().nullIfBlank()?.trim()
|
||||||
|
|
||||||
|
//Thumbnail is set as background of element in style attribute
|
||||||
|
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
|
||||||
|
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
|
||||||
|
}
|
||||||
|
|
||||||
|
genre = select(".ic").parents().attr("href").nullIfBlank()?.trim()?.substringAfterLast('/')
|
||||||
|
|
||||||
|
uploader = select("#gdn").text().nullIfBlank()?.trim()
|
||||||
|
|
||||||
|
//Parse the table
|
||||||
|
select("#gdd tr").forEach {
|
||||||
|
val left = it.select(".gdt1").text().nullIfBlank()?.trim() ?: return@forEach
|
||||||
|
val right = it.select(".gdt2").text().nullIfBlank()?.trim() ?: return@forEach
|
||||||
|
ignore {
|
||||||
|
when (left.removeSuffix(":")
|
||||||
|
.toLowerCase()) {
|
||||||
|
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
|
||||||
|
"visible" -> visible = right.nullIfBlank()
|
||||||
|
"language" -> {
|
||||||
|
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
|
||||||
|
translated = right.endsWith(TR_SUFFIX, true)
|
||||||
|
}
|
||||||
|
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
|
||||||
|
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
|
||||||
|
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Parse ratings
|
||||||
|
ignore {
|
||||||
|
averageRating = getElementById("rating_label")
|
||||||
|
.text()
|
||||||
|
.removePrefix("Average:")
|
||||||
|
.trim()
|
||||||
|
.nullIfBlank()
|
||||||
|
?.toDouble()
|
||||||
|
ratingCount = getElementById("rating_count")
|
||||||
|
.text()
|
||||||
|
.trim()
|
||||||
|
.nullIfBlank()
|
||||||
|
?.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
//Parse tags
|
||||||
|
tags.clear()
|
||||||
|
select("#taglist tr").forEach {
|
||||||
|
val namespace = it.select(".tc").text().removeSuffix(":")
|
||||||
|
val currentTags = it.select("div").map {
|
||||||
|
Tag(it.text().trim(),
|
||||||
|
it.hasClass("gtl"))
|
||||||
|
}
|
||||||
|
tags.put(namespace, currentTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
//Copy metadata to manga
|
||||||
|
SManga.create().apply {
|
||||||
|
copyTo(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("Unused method was called somehow!")
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("Unused method was called somehow!")
|
||||||
|
|
||||||
|
override fun fetchImageUrl(page: Page)
|
||||||
|
= client.newCall(imageUrlRequest(page))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { realImageUrlParse(it, page) }!!
|
||||||
|
|
||||||
|
fun realImageUrlParse(response: Response, page: Page)
|
||||||
|
= with(response.asJsoup()) {
|
||||||
|
val currentImage = getElementById("img").attr("src")
|
||||||
|
//TODO We cannot currently do this as page.url is immutable
|
||||||
|
//Each press of the retry button will choose another server
|
||||||
|
/*select("#loadfail").attr("onclick").nullIfBlank()?.let {
|
||||||
|
page.url = addParam(page.url, "nl", it.substring(it.indexOf('\'') + 1 .. it.lastIndexOf('\'') - 1))
|
||||||
|
}*/
|
||||||
|
currentImage
|
||||||
|
}!!
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("Unused method was called somehow!")
|
||||||
|
|
||||||
|
val cookiesHeader by lazy {
|
||||||
|
val cookies = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
//Setup settings
|
||||||
|
val settings = mutableListOf<String>()
|
||||||
|
|
||||||
|
//Do not show popular right now pane as we can't parse it
|
||||||
|
settings += "prn_n"
|
||||||
|
|
||||||
|
//Exclude every other language except the one we have selected
|
||||||
|
settings += "xl_" + languageMappings.filter { it.first != ehLang }
|
||||||
|
.flatMap { it.second }
|
||||||
|
.joinToString("x")
|
||||||
|
|
||||||
|
cookies.put("uconfig", buildSettings(settings))
|
||||||
|
|
||||||
|
buildCookies(cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
//Headers
|
||||||
|
override fun headersBuilder()
|
||||||
|
= super.headersBuilder().add("Cookie", cookiesHeader)!!
|
||||||
|
|
||||||
|
fun buildSettings(settings: List<String?>)
|
||||||
|
= settings.filterNotNull().joinToString(separator = "-")
|
||||||
|
|
||||||
|
fun buildCookies(cookies: Map<String, String>)
|
||||||
|
= cookies.entries.map {
|
||||||
|
"${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
|
||||||
|
}.joinToString(separator = "; ", postfix = ";")
|
||||||
|
|
||||||
|
fun addParam(url: String, param: String, value: String)
|
||||||
|
= Uri.parse(url)
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter(param, value)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
override val client = network.client.newBuilder()
|
||||||
|
.addNetworkInterceptor { chain ->
|
||||||
|
val newReq = chain
|
||||||
|
.request()
|
||||||
|
.newBuilder()
|
||||||
|
.addHeader("Cookie", cookiesHeader)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
chain.proceed(newReq)
|
||||||
|
}.build()!!
|
||||||
|
|
||||||
|
//Filters
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
GenreGroup(),
|
||||||
|
AdvancedGroup()
|
||||||
|
)
|
||||||
|
|
||||||
|
class GenreOption(name: String, val genreId: String) : Filter.CheckBox(name, false), UriFilter {
|
||||||
|
override fun addToUri(builder: Uri.Builder) {
|
||||||
|
builder.appendQueryParameter("f_" + genreId, if (state) "1" else "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenreGroup : UriGroup<GenreOption>("Genres", listOf(
|
||||||
|
GenreOption("Dōjinshi", "doujinshi"),
|
||||||
|
GenreOption("Manga", "manga"),
|
||||||
|
GenreOption("Artist CG", "artistcg"),
|
||||||
|
GenreOption("Game CG", "gamecg"),
|
||||||
|
GenreOption("Western", "western"),
|
||||||
|
GenreOption("Non-H", "non-h"),
|
||||||
|
GenreOption("Image Set", "imageset"),
|
||||||
|
GenreOption("Cosplay", "cosplay"),
|
||||||
|
GenreOption("Asian Porn", "asianporn"),
|
||||||
|
GenreOption("Misc", "misc")
|
||||||
|
))
|
||||||
|
|
||||||
|
class AdvancedOption(name: String, val param: String, defValue: Boolean = false) : Filter.CheckBox(name, defValue), UriFilter {
|
||||||
|
override fun addToUri(builder: Uri.Builder) {
|
||||||
|
if (state)
|
||||||
|
builder.appendQueryParameter(param, "on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RatingOption : Filter.Select<String>("Minimum Rating", arrayOf(
|
||||||
|
"Any",
|
||||||
|
"2 stars",
|
||||||
|
"3 stars",
|
||||||
|
"4 stars",
|
||||||
|
"5 stars"
|
||||||
|
)), UriFilter {
|
||||||
|
override fun addToUri(builder: Uri.Builder) {
|
||||||
|
if (state > 0) builder.appendQueryParameter("f_srdd", Integer.toString(state + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Explicit type arg for listOf() to workaround this: KT-16570
|
||||||
|
class AdvancedGroup : UriGroup<Filter<*>>("Advanced Options", listOf<Filter<*>>(
|
||||||
|
AdvancedOption("Search Gallery Name", "f_sname", true),
|
||||||
|
AdvancedOption("Search Gallery Tags", "f_stags", true),
|
||||||
|
AdvancedOption("Search Gallery Description", "f_sdesc"),
|
||||||
|
AdvancedOption("Search Torrent Filenames", "f_storr"),
|
||||||
|
AdvancedOption("Only Show Galleries With Torrents", "f_sto"),
|
||||||
|
AdvancedOption("Search Low-Power Tags", "f_sdt1"),
|
||||||
|
AdvancedOption("Search Downvoted Tags", "f_sdt2"),
|
||||||
|
AdvancedOption("Show Expunged Galleries", "f_sh"),
|
||||||
|
RatingOption()
|
||||||
|
))
|
||||||
|
|
||||||
|
//map languages to their internal ids
|
||||||
|
val languageMappings = listOf(
|
||||||
|
Pair("japanese", listOf("0", "1024", "2048")),
|
||||||
|
Pair("english", listOf("1", "1025", "2049")),
|
||||||
|
Pair("chinese", listOf("10", "1034", "2058")),
|
||||||
|
Pair("dutch", listOf("20", "1044", "2068")),
|
||||||
|
Pair("french", listOf("30", "1054", "2078")),
|
||||||
|
Pair("german", listOf("40", "1064", "2088")),
|
||||||
|
Pair("hungarian", listOf("50", "1074", "2098")),
|
||||||
|
Pair("italian", listOf("60", "1084", "2108")),
|
||||||
|
Pair("korean", listOf("70", "1094", "2118")),
|
||||||
|
Pair("polish", listOf("80", "1104", "2128")),
|
||||||
|
Pair("portuguese", listOf("90", "1114", "2138")),
|
||||||
|
Pair("russian", listOf("100", "1124", "2148")),
|
||||||
|
Pair("spanish", listOf("110", "1134", "2158")),
|
||||||
|
Pair("thai", listOf("120", "1144", "2168")),
|
||||||
|
Pair("vietnamese", listOf("130", "1154", "2178")),
|
||||||
|
Pair("n/a", listOf("254", "1278", "2302")),
|
||||||
|
Pair("other", listOf("255", "1279", "2303"))
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val QUERY_PREFIX = "?f_apply=Apply+Filter"
|
||||||
|
const val TR_SUFFIX = "TR"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery metadata storage model
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ExGalleryMetadata {
|
||||||
|
var url: String? = null
|
||||||
|
|
||||||
|
var thumbnailUrl: String? = null
|
||||||
|
|
||||||
|
var title: String? = null
|
||||||
|
var altTitle: String? = null
|
||||||
|
|
||||||
|
var genre: String? = null
|
||||||
|
|
||||||
|
var datePosted: Long? = null
|
||||||
|
var parent: String? = null
|
||||||
|
var visible: String? = null //Not a boolean
|
||||||
|
var language: String? = null
|
||||||
|
var translated: Boolean? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var length: Int? = null
|
||||||
|
var favorites: Int? = null
|
||||||
|
var ratingCount: Int? = null
|
||||||
|
var averageRating: Double? = null
|
||||||
|
|
||||||
|
var uploader: String? = null
|
||||||
|
|
||||||
|
val tags: MutableMap<String, List<Tag>> = mutableMapOf()
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private const val EH_ARTIST_NAMESPACE = "artist"
|
||||||
|
private const val EH_AUTHOR_NAMESPACE = "author"
|
||||||
|
|
||||||
|
private val ONGOING_SUFFIX = arrayOf(
|
||||||
|
"[ongoing]",
|
||||||
|
"(ongoing)",
|
||||||
|
"{ongoing}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||||
|
|
||||||
|
fun ExGalleryMetadata.copyTo(manga: SManga) {
|
||||||
|
url?.let { manga.url = it }
|
||||||
|
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||||
|
|
||||||
|
(title ?: altTitle)?.let { manga.title = it }
|
||||||
|
|
||||||
|
//Set artist (if we can find one)
|
||||||
|
tags[EH_ARTIST_NAMESPACE]?.let {
|
||||||
|
if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
|
||||||
|
}
|
||||||
|
//Set author (if we can find one)
|
||||||
|
tags[EH_AUTHOR_NAMESPACE]?.let {
|
||||||
|
if (it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name)
|
||||||
|
}
|
||||||
|
//Set genre
|
||||||
|
genre?.let { manga.genre = it }
|
||||||
|
|
||||||
|
//Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
|
||||||
|
//We default to completed
|
||||||
|
manga.status = SManga.COMPLETED
|
||||||
|
title?.let { t ->
|
||||||
|
if (ONGOING_SUFFIX.any {
|
||||||
|
t.endsWith(it, ignoreCase = true)
|
||||||
|
}) manga.status = SManga.ONGOING
|
||||||
|
}
|
||||||
|
|
||||||
|
//Build a nice looking description out of what we know
|
||||||
|
val titleDesc = StringBuilder()
|
||||||
|
title?.let { titleDesc += "Title: $it\n" }
|
||||||
|
altTitle?.let { titleDesc += "Alternate Title: $it\n" }
|
||||||
|
|
||||||
|
val detailsDesc = StringBuilder()
|
||||||
|
uploader?.let { detailsDesc += "Uploader: $it\n" }
|
||||||
|
datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||||
|
visible?.let { detailsDesc += "Visible: $it\n" }
|
||||||
|
language?.let {
|
||||||
|
detailsDesc += "Language: $it"
|
||||||
|
if (translated == true) detailsDesc += " TR"
|
||||||
|
detailsDesc += "\n"
|
||||||
|
}
|
||||||
|
size?.let { detailsDesc += "File Size: ${humanReadableByteCount(it, true)}\n" }
|
||||||
|
length?.let { detailsDesc += "Length: $it pages\n" }
|
||||||
|
favorites?.let { detailsDesc += "Favorited: $it times\n" }
|
||||||
|
averageRating?.let {
|
||||||
|
detailsDesc += "Rating: $it"
|
||||||
|
ratingCount?.let { detailsDesc += " ($it)" }
|
||||||
|
detailsDesc += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
val tagsDesc = buildTagsDescription(this)
|
||||||
|
|
||||||
|
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTagsDescription(metadata: ExGalleryMetadata)
|
||||||
|
= StringBuilder("Tags:\n").apply {
|
||||||
|
//BiConsumer only available in Java 8, we have to use destructuring here
|
||||||
|
metadata.tags.forEach { (namespace, tags) ->
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
|
||||||
|
this += "▪ $namespace: $joinedTags\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple tag model
|
||||||
|
*/
|
||||||
|
|
||||||
|
data class Tag(val name: String, val light: Boolean)
|
@ -0,0 +1,10 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uri filter
|
||||||
|
*/
|
||||||
|
interface UriFilter {
|
||||||
|
fun addToUri(builder: Uri.Builder)
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.ehentai
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UriGroup
|
||||||
|
*/
|
||||||
|
open class UriGroup<V>(name: String, state: List<V>) : Filter.Group<V>(name, state), UriFilter {
|
||||||
|
override fun addToUri(builder: Uri.Builder) {
|
||||||
|
state.forEach {
|
||||||
|
if (it is UriFilter) it.addToUri(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/all/nhentai/build.gradle
Normal file
18
src/all/nhentai/build.gradle
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: nhentai'
|
||||||
|
pkgNameSuffix = "all.nhentai"
|
||||||
|
extClass = '.NHJapanese; .NHEnglish; .NHChinese; .NHSpeechless; .NHCzech; .NHEsperanto; .NHMongolian; .NHSlovak; .NHArabic; .NHUkrainian'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
provided "com.google.code.gson:gson:2.8.0"
|
||||||
|
provided "com.github.salomonbrys.kotson:kotson:2.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,72 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private val ONGOING_SUFFIX = arrayOf(
|
||||||
|
"[ongoing]",
|
||||||
|
"(ongoing)",
|
||||||
|
"{ongoing}"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||||
|
|
||||||
|
fun NHentaiMetadata.copyTo(manga: SManga) {
|
||||||
|
url?.let { manga.url = it }
|
||||||
|
|
||||||
|
mediaId?.let { mid ->
|
||||||
|
NHentaiMetadata.typeToExtension(thumbnailImageType)?.let {
|
||||||
|
manga.thumbnail_url = "https://t.nhentai.net/galleries/$mid/thumb.$it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manga.title = englishTitle ?: japaneseTitle ?: shortTitle!!
|
||||||
|
|
||||||
|
//Set artist (if we can find one)
|
||||||
|
tags["artist"]?.let {
|
||||||
|
if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags["category"]?.let {
|
||||||
|
if (it.isNotEmpty()) manga.genre = it.joinToString(transform = Tag::name)
|
||||||
|
}
|
||||||
|
|
||||||
|
//Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
|
||||||
|
//We default to completed
|
||||||
|
manga.status = SManga.COMPLETED
|
||||||
|
englishTitle?.let { t ->
|
||||||
|
if (ONGOING_SUFFIX.any {
|
||||||
|
t.endsWith(it, ignoreCase = true)
|
||||||
|
}) manga.status = SManga.ONGOING
|
||||||
|
}
|
||||||
|
|
||||||
|
val titleDesc = StringBuilder()
|
||||||
|
englishTitle?.let { titleDesc += "English Title: $it\n" }
|
||||||
|
japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" }
|
||||||
|
shortTitle?.let { titleDesc += "Short Title: $it\n" }
|
||||||
|
|
||||||
|
val detailsDesc = StringBuilder()
|
||||||
|
uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||||
|
pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" }
|
||||||
|
favoritesCount?.let { detailsDesc += "Favorited: $it times\n" }
|
||||||
|
scanlator?.nullIfBlank()?.let { detailsDesc += "Scanlator: $it\n" }
|
||||||
|
|
||||||
|
val tagsDesc = buildTagsDescription(this)
|
||||||
|
|
||||||
|
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTagsDescription(metadata: NHentaiMetadata)
|
||||||
|
= StringBuilder("Tags:\n").apply {
|
||||||
|
//BiConsumer only available in Java 8, we have to use destructuring here
|
||||||
|
metadata.tags.forEach { (namespace, tags) ->
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
|
||||||
|
this += "▪ $namespace: $joinedTags\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NHentai languages
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NHJapanese : NHentai("ja", "japanese")
|
||||||
|
class NHEnglish : NHentai("en", "english")
|
||||||
|
class NHChinese : NHentai("zh", "chinese")
|
||||||
|
class NHSpeechless : NHentai("none", "speechless")
|
||||||
|
class NHCzech : NHentai("cs", "czech")
|
||||||
|
class NHEsperanto : NHentai("eo", "esperanto")
|
||||||
|
class NHMongolian : NHentai("mn", "mongolian")
|
||||||
|
class NHSlovak : NHentai("sk", "slovak")
|
||||||
|
class NHArabic : NHentai("ar", "arabic")
|
||||||
|
class NHUkrainian : NHentai("uk", "ukrainian")
|
||||||
|
|
||||||
|
fun getAllNHentaiLanguages() = listOf(
|
||||||
|
NHJapanese(),
|
||||||
|
NHEnglish(),
|
||||||
|
NHChinese(),
|
||||||
|
NHSpeechless(),
|
||||||
|
NHCzech(),
|
||||||
|
NHEsperanto(),
|
||||||
|
NHMongolian(),
|
||||||
|
NHSlovak(),
|
||||||
|
NHArabic(),
|
||||||
|
NHUkrainian()
|
||||||
|
)
|
@ -0,0 +1,17 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append Strings to StringBuilder with '+' operator
|
||||||
|
*/
|
||||||
|
operator fun StringBuilder.plusAssign(other: String) {
|
||||||
|
append(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return null if String is blank, otherwise returns the original String
|
||||||
|
* @returns null if the String is blank, otherwise returns the original String
|
||||||
|
*/
|
||||||
|
fun String?.nullIfBlank(): String? = if (isNullOrBlank())
|
||||||
|
null
|
||||||
|
else
|
||||||
|
this
|
@ -0,0 +1,216 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.github.salomonbrys.kotson.*
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NHentai source
|
||||||
|
*/
|
||||||
|
|
||||||
|
open class NHentai(override val lang: String, val nhLang: String) : HttpSource() {
|
||||||
|
override val name = "nhentai"
|
||||||
|
|
||||||
|
override val baseUrl = "https://nhentai.net"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
//TODO There is currently no way to get the most popular mangas
|
||||||
|
//TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen
|
||||||
|
override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page)
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon()
|
||||||
|
uri.appendQueryParameter("query", "language:$nhLang $query")
|
||||||
|
uri.appendQueryParameter("page", page.toString())
|
||||||
|
filters.forEach {
|
||||||
|
if (it is UriFilter)
|
||||||
|
it.addToUri(uri)
|
||||||
|
}
|
||||||
|
return nhGet(uri.toString(), page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response) = parseResultPage(response)
|
||||||
|
|
||||||
|
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", getFilterList())
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response)
|
||||||
|
= parseGallery(jsonParser.parse(response.body().string()).obj)
|
||||||
|
|
||||||
|
//Hack so we can use a different URL for fetching manga details and opening the details in the browser
|
||||||
|
override fun fetchMangaDetails(manga: SManga)
|
||||||
|
= client.newCall(urlToDetailsRequest(manga.url))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
mangaDetailsParse(response).apply { initialized = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga) = nhGet(manga.url)
|
||||||
|
|
||||||
|
fun urlToDetailsRequest(url: String) = nhGet("$baseUrl/api/gallery/${url.substringAfterLast('/')}")
|
||||||
|
|
||||||
|
fun parseResultPage(response: Response): MangasPage {
|
||||||
|
val res = jsonParser.parse(response.body().string()).obj
|
||||||
|
|
||||||
|
res["error"]?.let {
|
||||||
|
throw RuntimeException("An error occurred while performing the search: $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = res.getAsJsonArray("result")?.map {
|
||||||
|
parseGallery(it.obj)
|
||||||
|
}
|
||||||
|
val numPages = res["num_pages"].nullInt
|
||||||
|
if (results != null && numPages != null)
|
||||||
|
return MangasPage(results, numPages > response.request().tag() as Int)
|
||||||
|
return MangasPage(emptyList(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rawParseGallery(obj: JsonObject) = NHentaiMetadata().apply {
|
||||||
|
uploadDate = obj["upload_date"].nullLong
|
||||||
|
|
||||||
|
favoritesCount = obj["num_favorites"].nullLong
|
||||||
|
|
||||||
|
mediaId = obj["media_id"].nullString
|
||||||
|
|
||||||
|
obj["title"].nullObj?.let {
|
||||||
|
japaneseTitle = it["japanese"].nullString
|
||||||
|
shortTitle = it["pretty"].nullString
|
||||||
|
englishTitle = it["english"].nullString
|
||||||
|
}
|
||||||
|
|
||||||
|
obj["images"].nullObj?.let {
|
||||||
|
coverImageType = it["cover"]?.get("t").nullString
|
||||||
|
it["pages"].nullArray?.map {
|
||||||
|
it.nullObj?.get("t").nullString
|
||||||
|
}?.filterNotNull()?.let {
|
||||||
|
pageImageTypes.clear()
|
||||||
|
pageImageTypes.addAll(it)
|
||||||
|
}
|
||||||
|
thumbnailImageType = it["thumbnail"]?.get("t").nullString
|
||||||
|
}
|
||||||
|
|
||||||
|
scanlator = obj["scanlator"].nullString
|
||||||
|
|
||||||
|
id = obj["id"]?.asLong
|
||||||
|
|
||||||
|
obj["tags"].nullArray?.map {
|
||||||
|
val asObj = it.obj
|
||||||
|
Pair(asObj["type"].nullString, asObj["name"].nullString)
|
||||||
|
}?.apply {
|
||||||
|
tags.clear()
|
||||||
|
}?.forEach {
|
||||||
|
if (it.first != null && it.second != null)
|
||||||
|
tags.getOrPut(it.first!!, { mutableListOf<Tag>() }).add(Tag(it.second!!, false))
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseGallery(obj: JsonObject) = SManga.create().apply {
|
||||||
|
rawParseGallery(obj).copyTo(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lazyLoadMetadata(url: String) =
|
||||||
|
client.newCall(urlToDetailsRequest(url))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
|
rawParseGallery(jsonParser.parse(it.body().string()).obj)
|
||||||
|
}!!
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga)
|
||||||
|
= Observable.just(listOf(SChapter.create().apply {
|
||||||
|
url = manga.url
|
||||||
|
name = "Chapter"
|
||||||
|
chapter_number = 1f
|
||||||
|
}))!!
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter)
|
||||||
|
= lazyLoadMetadata(chapter.url).map { metadata ->
|
||||||
|
if (metadata.mediaId == null) emptyList()
|
||||||
|
else
|
||||||
|
metadata.pageImageTypes.mapIndexed { index, s ->
|
||||||
|
val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s)
|
||||||
|
Page(index, imageUrl!!, imageUrl)
|
||||||
|
}
|
||||||
|
}!!
|
||||||
|
|
||||||
|
override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!!
|
||||||
|
|
||||||
|
fun imageUrlFromType(mediaId: String, page: Int, t: String) = NHentaiMetadata.typeToExtension(t)?.let {
|
||||||
|
"https://i.nhentai.net/galleries/$mediaId/$page.$it"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(SortFilter())
|
||||||
|
|
||||||
|
private class SortFilter : UriSelectFilter("Sort", "sort", arrayOf(
|
||||||
|
Pair("date", "Date"),
|
||||||
|
Pair("popular", "Popularity")
|
||||||
|
), firstIsUnspecified = false)
|
||||||
|
|
||||||
|
private fun nhGet(url: String, tag: Any? = null) = GET(url)
|
||||||
|
.newBuilder()
|
||||||
|
//Requested by nhentai admins to use a custom user agent
|
||||||
|
.header("User-Agent",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/56.0.2924.87 " +
|
||||||
|
"Safari/537.36 " +
|
||||||
|
"Tachiyomi/1.0")
|
||||||
|
.tag(tag).build()!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||||
|
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||||
|
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||||
|
*/
|
||||||
|
//vals: <name, display>
|
||||||
|
private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
|
||||||
|
val firstIsUnspecified: Boolean = true,
|
||||||
|
defaultValue: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
if (state != 0 || !firstIsUnspecified)
|
||||||
|
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a filter that is able to modify a URI.
|
||||||
|
*/
|
||||||
|
private interface UriFilter {
|
||||||
|
fun addToUri(uri: Uri.Builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val jsonParser by lazy {
|
||||||
|
JsonParser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NHentai metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NHentaiMetadata {
|
||||||
|
|
||||||
|
var id: Long? = null
|
||||||
|
|
||||||
|
var url: String?
|
||||||
|
get() = id?.let { "/g/$it" }
|
||||||
|
set(a) {
|
||||||
|
id = a?.substringAfterLast('/')?.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadDate: Long? = null
|
||||||
|
|
||||||
|
var favoritesCount: Long? = null
|
||||||
|
|
||||||
|
var mediaId: String? = null
|
||||||
|
|
||||||
|
var japaneseTitle: String? = null
|
||||||
|
var englishTitle: String? = null
|
||||||
|
var shortTitle: String? = null
|
||||||
|
|
||||||
|
var coverImageType: String? = null
|
||||||
|
var pageImageTypes: MutableList<String> = mutableListOf()
|
||||||
|
var thumbnailImageType: String? = null
|
||||||
|
|
||||||
|
var scanlator: String? = null
|
||||||
|
|
||||||
|
val tags: MutableMap<String, MutableList<Tag>> = mutableMapOf()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun typeToExtension(t: String?) =
|
||||||
|
when (t) {
|
||||||
|
"p" -> "png"
|
||||||
|
"j" -> "jpg"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.nhentai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple tag model
|
||||||
|
*/
|
||||||
|
|
||||||
|
data class Tag(val name: String, val light: Boolean)
|
13
src/en/mangago/build.gradle
Normal file
13
src/en/mangago/build.gradle
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: Mangago'
|
||||||
|
pkgNameSuffix = "en.mangago"
|
||||||
|
extClass = '.Mangago'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,245 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.mangago
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mangago source
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Mangago : ParsedHttpSource() {
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = true
|
||||||
|
override val name = "Mangago"
|
||||||
|
override val baseUrl = "https://www.mangago.me"
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient!!
|
||||||
|
|
||||||
|
//Hybrid selector that selects manga from either the genre listing or the search results
|
||||||
|
private val genreListingSelector = ".updatesli"
|
||||||
|
private val genreListingNextPageSelector = ".current+li > a"
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = genreListingSelector
|
||||||
|
|
||||||
|
private fun mangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
val linkElement = element.select(".thm-effect")
|
||||||
|
|
||||||
|
setUrlWithoutDomain(linkElement.attr("href"))
|
||||||
|
|
||||||
|
title = linkElement.attr("title")
|
||||||
|
|
||||||
|
thumbnail_url = linkElement.first().child(0).attr("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = genreListingNextPageSelector
|
||||||
|
|
||||||
|
//Hybrid selector that selects manga from either the genre listing or the search results
|
||||||
|
override fun searchMangaSelector() = "$genreListingSelector, .pic_list .box"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = genreListingNextPageSelector
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=view&e=")
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = genreListingSelector
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
//If text search is active use text search, otherwise use genre search
|
||||||
|
val url = if (query.isNotBlank()) {
|
||||||
|
Uri.parse("$baseUrl/r/l_search/")
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("name", query)
|
||||||
|
.appendQueryParameter("page", page.toString())
|
||||||
|
.toString()
|
||||||
|
} else {
|
||||||
|
val uri = Uri.parse("$baseUrl/genre/").buildUpon()
|
||||||
|
val genres = filters.flatMap {
|
||||||
|
(it as? GenreGroup)?.stateList ?: emptyList()
|
||||||
|
}
|
||||||
|
//Append included genres
|
||||||
|
val activeGenres = genres.filter { it.isIncluded() }
|
||||||
|
uri.appendPath(if (activeGenres.isEmpty())
|
||||||
|
"all"
|
||||||
|
else
|
||||||
|
activeGenres.joinToString(",", transform = { it.name }))
|
||||||
|
//Append page number
|
||||||
|
uri.appendPath(page.toString())
|
||||||
|
//Append excluded genres
|
||||||
|
uri.appendQueryParameter("e",
|
||||||
|
genres.filter { it.isExcluded() }
|
||||||
|
.joinToString(",", transform = GenreFilter::name))
|
||||||
|
//Append uri filters
|
||||||
|
filters.forEach {
|
||||||
|
if (it is UriFilter)
|
||||||
|
it.addToUri(uri)
|
||||||
|
}
|
||||||
|
uri.toString()
|
||||||
|
}
|
||||||
|
return GET(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = genreListingNextPageSelector
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
val coverElement = document.select(".left.cover > img")
|
||||||
|
|
||||||
|
title = coverElement.attr("alt")
|
||||||
|
|
||||||
|
thumbnail_url = coverElement.attr("src")
|
||||||
|
|
||||||
|
document.select(".manga_right td").forEach {
|
||||||
|
when (it.getElementsByTag("label").text().trim().toLowerCase()) {
|
||||||
|
"status:" -> {
|
||||||
|
status = when (it.getElementsByTag("span").first().text().trim().toLowerCase()) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"completed" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"author:" -> {
|
||||||
|
author = it.getElementsByTag("a").first().text()
|
||||||
|
}
|
||||||
|
"genre(s):" -> {
|
||||||
|
genre = it.getElementsByTag("a").joinToString(transform = { it.text() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
description = document.getElementsByClass("manga_summary").first().ownText().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=update_date&e=")
|
||||||
|
|
||||||
|
override fun chapterListSelector() = "#chapter_table > tbody > tr"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||||
|
val link = element.getElementsByTag("a")
|
||||||
|
|
||||||
|
setUrlWithoutDomain(link.attr("href"))
|
||||||
|
|
||||||
|
name = link.text().trim()
|
||||||
|
|
||||||
|
date_upload = dateFormat.parse(element.getElementsByClass("no").text().trim()).time
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document)
|
||||||
|
= document.getElementById("pagenavigation").getElementsByTag("a").mapIndexed { index, element ->
|
||||||
|
Page(index, element.attr("href"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document) = document.getElementById("page1").attr("src")!!
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
//Mangago does not support genre filtering and text search at the same time
|
||||||
|
Filter.Header("NOTE: Ignored if using text search!"),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Status"),
|
||||||
|
StatusFilter("Completed", "f"),
|
||||||
|
StatusFilter("Ongoing", "o"),
|
||||||
|
GenreGroup(),
|
||||||
|
SortFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GenreGroup : UriFilterGroup<GenreFilter>("Genres", listOf(
|
||||||
|
GenreFilter("Yaoi"),
|
||||||
|
GenreFilter("Doujinshi"),
|
||||||
|
GenreFilter("Shounen Ai"),
|
||||||
|
GenreFilter("Shoujo"),
|
||||||
|
GenreFilter("Yuri"),
|
||||||
|
GenreFilter("Romance"),
|
||||||
|
GenreFilter("Fantasy"),
|
||||||
|
GenreFilter("Smut"),
|
||||||
|
GenreFilter("Adult"),
|
||||||
|
GenreFilter("School Life"),
|
||||||
|
GenreFilter("Mystery"),
|
||||||
|
GenreFilter("Comedy"),
|
||||||
|
GenreFilter("Ecchi"),
|
||||||
|
GenreFilter("Shounen"),
|
||||||
|
GenreFilter("Martial Arts"),
|
||||||
|
GenreFilter("Shoujo Ai"),
|
||||||
|
GenreFilter("Supernatural"),
|
||||||
|
GenreFilter("Drama"),
|
||||||
|
GenreFilter("Action"),
|
||||||
|
GenreFilter("Adventure"),
|
||||||
|
GenreFilter("Harem"),
|
||||||
|
GenreFilter("Historical"),
|
||||||
|
GenreFilter("Horror"),
|
||||||
|
GenreFilter("Josei"),
|
||||||
|
GenreFilter("Mature"),
|
||||||
|
GenreFilter("Mecha"),
|
||||||
|
GenreFilter("Psychological"),
|
||||||
|
GenreFilter("Sci-fi"),
|
||||||
|
GenreFilter("Seinen"),
|
||||||
|
GenreFilter("Slice Of Life"),
|
||||||
|
GenreFilter("Sports"),
|
||||||
|
GenreFilter("Gender Bender"),
|
||||||
|
GenreFilter("Tragedy"),
|
||||||
|
GenreFilter("Bara"),
|
||||||
|
GenreFilter("Shotacon")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class GenreFilter(name: String) : Filter.TriState(name)
|
||||||
|
|
||||||
|
private class StatusFilter(name: String, val uriParam: String) : Filter.CheckBox(name, true), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
uri.appendQueryParameter(uriParam, if (state) "1" else "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SortFilter : UriSelectFilter("Sort", "sortby", arrayOf(
|
||||||
|
Pair("random", "Random"),
|
||||||
|
Pair("view", "Views"),
|
||||||
|
Pair("comment_count", "Comment Count"),
|
||||||
|
Pair("create_date", "Creation Date"),
|
||||||
|
Pair("update_date", "Update Date")
|
||||||
|
))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||||
|
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||||
|
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||||
|
*/
|
||||||
|
//vals: <name, display>
|
||||||
|
private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
|
||||||
|
val firstIsUnspecified: Boolean = true,
|
||||||
|
defaultValue: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
if (state != 0 || !firstIsUnspecified)
|
||||||
|
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uri filter group
|
||||||
|
*/
|
||||||
|
private open class UriFilterGroup<V>(name: String, val stateList: List<V>) : Filter.Group<V>(name, stateList), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
stateList.forEach {
|
||||||
|
if (it is UriFilter)
|
||||||
|
it.addToUri(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a filter that is able to modify a URI.
|
||||||
|
*/
|
||||||
|
private interface UriFilter {
|
||||||
|
fun addToUri(uri: Uri.Builder)
|
||||||
|
}
|
||||||
|
}
|
13
src/en/mangapark/build.gradle
Normal file
13
src/en/mangapark/build.gradle
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: MangaPark'
|
||||||
|
pkgNameSuffix = "en.mangapark"
|
||||||
|
extClass = '.MangaPark'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,291 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.mangapark
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MangaPark source
|
||||||
|
*/
|
||||||
|
|
||||||
|
class MangaPark : ParsedHttpSource() {
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
override val name = "MangaPark"
|
||||||
|
override val baseUrl = "https://mangapark.me"
|
||||||
|
|
||||||
|
private val directorySelector = ".item"
|
||||||
|
private val directoryUrl = "/genre"
|
||||||
|
private val directoryNextPageSelector = ".paging.full > li:last-child > a"
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("MMM d, yyyy, HH:mm a", Locale.ENGLISH)
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = directorySelector
|
||||||
|
|
||||||
|
private fun mangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
val coverElement = element.getElementsByClass("cover").first()
|
||||||
|
url = coverElement.attr("href")
|
||||||
|
|
||||||
|
title = coverElement.attr("title")
|
||||||
|
|
||||||
|
thumbnail_url = coverElement.getElementsByTag("img").attr("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = directoryNextPageSelector
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = ".item"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = ".paging > li:last-child > a"
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?views")
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = directorySelector
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val uri = Uri.parse("$baseUrl/search").buildUpon()
|
||||||
|
uri.appendQueryParameter("q", query)
|
||||||
|
filters.forEach {
|
||||||
|
if (it is UriFilter)
|
||||||
|
it.addToUri(uri)
|
||||||
|
}
|
||||||
|
uri.appendQueryParameter("page", page.toString())
|
||||||
|
return GET(uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = directoryNextPageSelector
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
val coverElement = document.select(".cover > img").first()
|
||||||
|
|
||||||
|
title = coverElement.attr("title")
|
||||||
|
|
||||||
|
thumbnail_url = coverElement.attr("src")
|
||||||
|
|
||||||
|
document.select(".attr > tbody > tr").forEach {
|
||||||
|
val type = it.getElementsByTag("th").first().text().trim().toLowerCase()
|
||||||
|
when (type) {
|
||||||
|
"author(s)" -> {
|
||||||
|
author = it.getElementsByTag("a").joinToString(transform = Element::text)
|
||||||
|
}
|
||||||
|
"artist(s)" -> {
|
||||||
|
artist = it.getElementsByTag("a").joinToString(transform = Element::text)
|
||||||
|
}
|
||||||
|
"genre(s)" -> {
|
||||||
|
genre = it.getElementsByTag("a").joinToString(transform = Element::text)
|
||||||
|
}
|
||||||
|
"status" -> {
|
||||||
|
status = when (it.getElementsByTag("td").text().trim().toLowerCase()) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"completed" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
description = document.getElementsByClass("summary").text().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?latest")
|
||||||
|
|
||||||
|
//TODO MangaPark has "versioning"
|
||||||
|
//TODO Currently we just use the version that is expanded by default
|
||||||
|
//TODO Maybe make it possible for users to view the other versions as well?
|
||||||
|
override fun chapterListSelector() = ".stream:not(.collapsed) .volume .chapter li"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||||
|
url = element.select("em > a").last().attr("href")
|
||||||
|
|
||||||
|
name = element.getElementsByClass("ch").text()
|
||||||
|
|
||||||
|
date_upload = dateFormat.parse(element.getElementsByTag("i").text().trim()).time
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document)
|
||||||
|
= document.getElementsByClass("img").map {
|
||||||
|
Page(it.attr("i").toInt() - 1, "", it.attr("src"))
|
||||||
|
}
|
||||||
|
|
||||||
|
//Unused, we can get image urls directly from the chapter page
|
||||||
|
override fun imageUrlParse(document: Document)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
AuthorArtistText(),
|
||||||
|
SearchTypeFilter("Title query", "name-match"),
|
||||||
|
SearchTypeFilter("Author/Artist query", "autart-match"),
|
||||||
|
SortFilter(),
|
||||||
|
GenreGroup(),
|
||||||
|
GenreInclusionFilter(),
|
||||||
|
ChapterCountFilter(),
|
||||||
|
StatusFilter(),
|
||||||
|
RatingFilter(),
|
||||||
|
TypeFilter(),
|
||||||
|
YearFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SearchTypeFilter(name: String, val uriParam: String) :
|
||||||
|
Filter.Select<String>(name, STATE_MAP), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
uri.appendQueryParameter(uriParam, STATE_MAP[state])
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val STATE_MAP = arrayOf("contain", "begin", "end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AuthorArtistText : Filter.Text("Author/Artist"), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
uri.appendQueryParameter("autart", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GenreFilter(val uriParam: String, displayName: String) : Filter.TriState(displayName)
|
||||||
|
|
||||||
|
private class GenreGroup : Filter.Group<GenreFilter>("Genres", listOf(
|
||||||
|
GenreFilter("4-koma", "4 koma"),
|
||||||
|
GenreFilter("action", "Action"),
|
||||||
|
GenreFilter("adult", "Adult"),
|
||||||
|
GenreFilter("adventure", "Adventure"),
|
||||||
|
GenreFilter("award-winning", "Award winning"),
|
||||||
|
GenreFilter("comedy", "Comedy"),
|
||||||
|
GenreFilter("cooking", "Cooking"),
|
||||||
|
GenreFilter("demons", "Demons"),
|
||||||
|
GenreFilter("doujinshi", "Doujinshi"),
|
||||||
|
GenreFilter("drama", "Drama"),
|
||||||
|
GenreFilter("ecchi", "Ecchi"),
|
||||||
|
GenreFilter("fantasy", "Fantasy"),
|
||||||
|
GenreFilter("gender-bender", "Gender bender"),
|
||||||
|
GenreFilter("harem", "Harem"),
|
||||||
|
GenreFilter("historical", "Historical"),
|
||||||
|
GenreFilter("horror", "Horror"),
|
||||||
|
GenreFilter("josei", "Josei"),
|
||||||
|
GenreFilter("magic", "Magic"),
|
||||||
|
GenreFilter("martial-arts", "Martial arts"),
|
||||||
|
GenreFilter("mature", "Mature"),
|
||||||
|
GenreFilter("mecha", "Mecha"),
|
||||||
|
GenreFilter("medical", "Medical"),
|
||||||
|
GenreFilter("music", "Music"),
|
||||||
|
GenreFilter("mystery", "Mystery"),
|
||||||
|
GenreFilter("one-shot", "One shot"),
|
||||||
|
GenreFilter("psychological", "Psychological"),
|
||||||
|
GenreFilter("romance", "Romance"),
|
||||||
|
GenreFilter("school-life", "School life"),
|
||||||
|
GenreFilter("sci-fi", "Sci fi"),
|
||||||
|
GenreFilter("seinen", "Seinen"),
|
||||||
|
GenreFilter("shoujo", "Shoujo"),
|
||||||
|
GenreFilter("shoujo-ai", "Shoujo ai"),
|
||||||
|
GenreFilter("shounen", "Shounen"),
|
||||||
|
GenreFilter("shounen-ai", "Shounen ai"),
|
||||||
|
GenreFilter("slice-of-life", "Slice of life"),
|
||||||
|
GenreFilter("smut", "Smut"),
|
||||||
|
GenreFilter("sports", "Sports"),
|
||||||
|
GenreFilter("supernatural", "Supernatural"),
|
||||||
|
GenreFilter("tragedy", "Tragedy"),
|
||||||
|
GenreFilter("webtoon", "Webtoon"),
|
||||||
|
GenreFilter("yaoi", "Yaoi"),
|
||||||
|
GenreFilter("yuri", "Yuri")
|
||||||
|
)), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
uri.appendQueryParameter("genres", state.filter { it.isIncluded() }.map { it.uriParam }.joinToString(","))
|
||||||
|
uri.appendQueryParameter("genres-exclude", state.filter { it.isExcluded() }.map { it.uriParam }.joinToString(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GenreInclusionFilter : UriSelectFilter("Genre inclusion", "genres-mode", arrayOf(
|
||||||
|
Pair("and", "And mode"),
|
||||||
|
Pair("or", "Or mode")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class ChapterCountFilter : UriSelectFilter("Chapter count", "chapters", arrayOf(
|
||||||
|
Pair("any", "Any"),
|
||||||
|
Pair("1", "1 +"),
|
||||||
|
Pair("5", "5 +"),
|
||||||
|
Pair("10", "10 +"),
|
||||||
|
Pair("20", "20 +"),
|
||||||
|
Pair("30", "30 +"),
|
||||||
|
Pair("40", "40 +"),
|
||||||
|
Pair("50", "50 +"),
|
||||||
|
Pair("100", "100 +"),
|
||||||
|
Pair("150", "150 +"),
|
||||||
|
Pair("200", "200 +")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class StatusFilter : UriSelectFilter("Status", "status", arrayOf(
|
||||||
|
Pair("any", "Any"),
|
||||||
|
Pair("completed", "Completed"),
|
||||||
|
Pair("ongoing", "Ongoing")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class RatingFilter : UriSelectFilter("Rating", "rating", arrayOf(
|
||||||
|
Pair("any", "Any"),
|
||||||
|
Pair("5", "5 stars"),
|
||||||
|
Pair("4", "4 stars"),
|
||||||
|
Pair("3", "3 stars"),
|
||||||
|
Pair("2", "2 stars"),
|
||||||
|
Pair("1", "1 star"),
|
||||||
|
Pair("0", "0 stars")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class TypeFilter : UriSelectFilter("Type", "types", arrayOf(
|
||||||
|
Pair("any", "Any"),
|
||||||
|
Pair("manga", "Japanese Manga"),
|
||||||
|
Pair("manhwa", "Korean Manhwa"),
|
||||||
|
Pair("manhua", "Chinese Manhua"),
|
||||||
|
Pair("unknown", "Unknown")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class YearFilter : UriSelectFilter("Release year", "years",
|
||||||
|
arrayOf(Pair("any", "Any"),
|
||||||
|
//Get all years between today and 1946
|
||||||
|
*(Calendar.getInstance().get(Calendar.YEAR) downTo 1946).map {
|
||||||
|
Pair(it.toString(), it.toString())
|
||||||
|
}.toTypedArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SortFilter : UriSelectFilter("Sort", "orderby", arrayOf(
|
||||||
|
Pair("a-z", "A-Z"),
|
||||||
|
Pair("views", "Views"),
|
||||||
|
Pair("rating", "Rating"),
|
||||||
|
Pair("latest", "Latest"),
|
||||||
|
Pair("add", "New manga")
|
||||||
|
), firstIsUnspecified = false, defaultValue = 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||||
|
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||||
|
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||||
|
*/
|
||||||
|
//vals: <name, display>
|
||||||
|
private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
|
||||||
|
val firstIsUnspecified: Boolean = true,
|
||||||
|
defaultValue: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
if (state != 0 || !firstIsUnspecified)
|
||||||
|
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a filter that is able to modify a URI.
|
||||||
|
*/
|
||||||
|
private interface UriFilter {
|
||||||
|
fun addToUri(uri: Uri.Builder)
|
||||||
|
}
|
||||||
|
}
|
18
src/en/tapastic/build.gradle
Normal file
18
src/en/tapastic/build.gradle
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: Tapastic'
|
||||||
|
pkgNameSuffix = "en.tapastic"
|
||||||
|
extClass = '.Tapastic'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
provided "com.google.code.gson:gson:2.8.0"
|
||||||
|
provided "com.github.salomonbrys.kotson:kotson:2.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,212 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.tapastic
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.github.salomonbrys.kotson.*
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
class Tapastic : ParsedHttpSource() {
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = true
|
||||||
|
override val name = "Tapastic"
|
||||||
|
override val baseUrl = "https://tapas.io"
|
||||||
|
|
||||||
|
private val browseMangaSelector = ".content-item"
|
||||||
|
private val nextPageSelector = "a.paging-btn.next"
|
||||||
|
|
||||||
|
private val jsonParser by lazy { JsonParser() }
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = browseMangaSelector
|
||||||
|
|
||||||
|
private fun mangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
val thumb = element.getElementsByClass("thumb-wrap")
|
||||||
|
|
||||||
|
url = thumb.attr("href")
|
||||||
|
|
||||||
|
title = element.getElementsByClass("title").text().trim()
|
||||||
|
|
||||||
|
thumbnail_url = thumb.select("img").attr("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = nextPageSelector
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = "$browseMangaSelector, .search-item-wrap"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = nextPageSelector
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=POPULAR")
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = browseMangaSelector
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
//If there is any search text, use text search, otherwise use filter search
|
||||||
|
val uri = if (query.isNotBlank()) {
|
||||||
|
Uri.parse("$baseUrl/search")
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("t", "COMICS")
|
||||||
|
.appendQueryParameter("q", query)
|
||||||
|
} else {
|
||||||
|
val uri = Uri.parse("$baseUrl/comics").buildUpon()
|
||||||
|
//Append uri filters
|
||||||
|
filters.forEach {
|
||||||
|
if (it is UriFilter)
|
||||||
|
it.addToUri(uri)
|
||||||
|
}
|
||||||
|
uri
|
||||||
|
}
|
||||||
|
//Append page number
|
||||||
|
uri.appendQueryParameter("pageNumber", page.toString())
|
||||||
|
return GET(uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = nextPageSelector
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
title = document.getElementsByClass("series-header-title").text().trim()
|
||||||
|
|
||||||
|
author = document.getElementsByClass("name").text().trim()
|
||||||
|
artist = author
|
||||||
|
|
||||||
|
description = document.getElementById("series-desc-body").text().trim()
|
||||||
|
|
||||||
|
genre = document.getElementsByClass("genre").text()
|
||||||
|
|
||||||
|
status = SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=FRESH")
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response)
|
||||||
|
//Chapters are stored in JavaScript as JSON!
|
||||||
|
= response.asJsoup().getElementsByTag("script").filter {
|
||||||
|
it.data().trim().startsWith("var _data")
|
||||||
|
}.flatMap {
|
||||||
|
val text = it.data()
|
||||||
|
val episodeVar = text.indexOf("episodeList")
|
||||||
|
if (episodeVar == -1)
|
||||||
|
return@flatMap emptyList<SChapter>()
|
||||||
|
|
||||||
|
val episodeLeftBracket = text.indexOf('[', startIndex = episodeVar)
|
||||||
|
if (episodeLeftBracket == -1)
|
||||||
|
return@flatMap emptyList<SChapter>()
|
||||||
|
|
||||||
|
val endOfLine = text.indexOf('\n', startIndex = episodeLeftBracket)
|
||||||
|
if (endOfLine == -1)
|
||||||
|
return@flatMap emptyList<SChapter>()
|
||||||
|
|
||||||
|
val episodeRightBracket = text.lastIndexOf(']', startIndex = endOfLine)
|
||||||
|
if (episodeRightBracket == -1)
|
||||||
|
return@flatMap emptyList<SChapter>()
|
||||||
|
|
||||||
|
val episodeListText = text.substring(episodeLeftBracket..episodeRightBracket)
|
||||||
|
|
||||||
|
jsonParser.parse(episodeListText).array.map {
|
||||||
|
val json = it.asJsonObject
|
||||||
|
//Ensure that the chapter is published (tapastic allows scheduling chapters)
|
||||||
|
if (json["orgScene"].int != 0)
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "/episode/${json["id"].string}"
|
||||||
|
|
||||||
|
name = json["title"].string
|
||||||
|
|
||||||
|
date_upload = json["publishDate"].long
|
||||||
|
|
||||||
|
chapter_number = json["scene"].float
|
||||||
|
}
|
||||||
|
else null
|
||||||
|
}.filterNotNull().sortedByDescending(SChapter::chapter_number)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector()
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document)
|
||||||
|
= document.getElementsByClass("art-image").mapIndexed { index, element ->
|
||||||
|
Page(index, "", element.attr("src"))
|
||||||
|
}
|
||||||
|
|
||||||
|
//Unused, we can get image urls directly from the chapter page
|
||||||
|
override fun imageUrlParse(document: Document)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
//Tapastic does not support genre filtering and text search at the same time
|
||||||
|
Filter.Header("NOTE: Ignored if using text search!"),
|
||||||
|
Filter.Separator(),
|
||||||
|
FilterFilter(),
|
||||||
|
GenreFilter(),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Sort is ignored when filter is active!"),
|
||||||
|
SortFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FilterFilter : UriSelectFilter("Filter", "browse", arrayOf(
|
||||||
|
Pair("ALL", "None"),
|
||||||
|
Pair("POPULAR", "Popular"),
|
||||||
|
Pair("TRENDING", "Trending"),
|
||||||
|
Pair("FRESH", "Fresh"),
|
||||||
|
Pair("TAPASTIC", "Staff Picks")
|
||||||
|
), firstIsUnspecified = false, defaultValue = 1)
|
||||||
|
|
||||||
|
private class GenreFilter : UriSelectFilter("Genre", "genreIds", arrayOf(
|
||||||
|
Pair("", "Any"),
|
||||||
|
Pair("7", "Action"),
|
||||||
|
Pair("2", "Comedy"),
|
||||||
|
Pair("8", "Drama"),
|
||||||
|
Pair("3", "Fantasy"),
|
||||||
|
Pair("9", "Gaming"),
|
||||||
|
Pair("6", "Horror"),
|
||||||
|
Pair("10", "Mystery"),
|
||||||
|
Pair("5", "Romance"),
|
||||||
|
Pair("4", "Science Fiction"),
|
||||||
|
Pair("1", "Slice of Life")
|
||||||
|
))
|
||||||
|
|
||||||
|
private class SortFilter : UriSelectFilter("Sort", "sortType", arrayOf(
|
||||||
|
Pair("SUBSCRIBE", "Subscribers"),
|
||||||
|
Pair("LIKE", "Likes"),
|
||||||
|
Pair("VIEW", "Views"),
|
||||||
|
Pair("COMMENT", "Comments"),
|
||||||
|
Pair("CREATED", "Date"),
|
||||||
|
Pair("TITLE", "Name")
|
||||||
|
))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||||
|
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||||
|
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||||
|
*/
|
||||||
|
//vals: <name, display>
|
||||||
|
private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
|
||||||
|
val firstIsUnspecified: Boolean = true,
|
||||||
|
defaultValue: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
if (state != 0 || !firstIsUnspecified)
|
||||||
|
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a filter that is able to modify a URI.
|
||||||
|
*/
|
||||||
|
private interface UriFilter {
|
||||||
|
fun addToUri(uri: Uri.Builder)
|
||||||
|
}
|
||||||
|
}
|
13
src/ja/senmanga/build.gradle
Normal file
13
src/ja/senmanga/build.gradle
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: Sen Manga'
|
||||||
|
pkgNameSuffix = "ja.senmanga"
|
||||||
|
extClass = '.SenManga'
|
||||||
|
extVersionCode = 1
|
||||||
|
extVersionSuffix = 1
|
||||||
|
libVersion = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
@ -0,0 +1,327 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.ja.senmanga
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sen Manga source
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SenManga : ParsedHttpSource() {
|
||||||
|
override val lang: String = "ja"
|
||||||
|
|
||||||
|
//Latest updates currently returns duplicate manga as it separates manga into chapters
|
||||||
|
override val supportsLatest = false
|
||||||
|
override val name = "Sen Manga"
|
||||||
|
override val baseUrl = "http://raw.senmanga.com"
|
||||||
|
|
||||||
|
override val client = super.client.newBuilder().addInterceptor {
|
||||||
|
//Intercept any image requests and add a referer to them
|
||||||
|
//Enables bandwidth stealing feature
|
||||||
|
val request = if (it.request().url().pathSegments().firstOrNull()?.trim()?.toLowerCase() == "viewer") {
|
||||||
|
it.request().newBuilder()
|
||||||
|
.addHeader("Referer", it.request().url().newBuilder()
|
||||||
|
.removePathSegment(0)
|
||||||
|
.toString())
|
||||||
|
.build()
|
||||||
|
} else it.request()
|
||||||
|
it.proceed(request)
|
||||||
|
}.build()!!
|
||||||
|
|
||||||
|
//Sen Manga doesn't follow the specs and decides to use multiple elements with the same ID on the page...
|
||||||
|
override fun popularMangaSelector() = "#manga-list"
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
val linkElement = element.select("h1 a")
|
||||||
|
|
||||||
|
url = linkElement.attr("href")
|
||||||
|
|
||||||
|
title = linkElement.text()
|
||||||
|
|
||||||
|
thumbnail_url = baseUrl + element.getElementsByClass("series-cover").attr("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = "#Navigation > span > ul > li:nth-last-child(2)"
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||||
|
popularMangaFromElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasNextPage = document.select(popularMangaNextPageSelector()).let {
|
||||||
|
it.isNotEmpty() && it.text().trim().toLowerCase() == "Next"
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(mangas, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = ".search-results"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
val coverImage = element.getElementsByTag("img")
|
||||||
|
|
||||||
|
url = coverImage.parents().attr("href")
|
||||||
|
|
||||||
|
title = coverImage.attr("alt")
|
||||||
|
|
||||||
|
thumbnail_url = baseUrl + coverImage.attr("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
//Sen Manga search returns one page max!
|
||||||
|
override fun searchMangaNextPageSelector() = null
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/Manga/?order=popular&page=$page")
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector()
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response)
|
||||||
|
= if (response.request().url().pathSegments().firstOrNull()?.toLowerCase() != "search.php") {
|
||||||
|
//Use popular manga parser if we are not actually doing text search
|
||||||
|
popularMangaParse(response)
|
||||||
|
} else {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
|
||||||
|
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||||
|
searchMangaFromElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList)
|
||||||
|
= GET(if (query.isNullOrBlank()) {
|
||||||
|
val genreFilter = filters.find { it is GenreFilter } as GenreFilter
|
||||||
|
val sortFilter = filters.find { it is SortFilter } as SortFilter
|
||||||
|
//If genre sort is not active or sort settings are changed
|
||||||
|
if (!sortFilter.isDefault() || genreFilter.genrePath() == ALL_GENRES_PATH) {
|
||||||
|
val uri = Uri.parse("$baseUrl/Manga/")
|
||||||
|
.buildUpon()
|
||||||
|
sortFilter.addToUri(uri)
|
||||||
|
uri.toString()
|
||||||
|
} else "$baseUrl/directory/category/${genreFilter.genrePath()}/"
|
||||||
|
} else {
|
||||||
|
Uri.parse("$baseUrl/Search.php")
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("q", query)
|
||||||
|
.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector()
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
title = document.select("h1[itemprop=name]").text()
|
||||||
|
|
||||||
|
thumbnail_url = baseUrl + document.select(".cover > img").attr("src")
|
||||||
|
|
||||||
|
val seriesDesc = document.getElementsByClass("series_desc")
|
||||||
|
|
||||||
|
//Get the next paragraph after paragraph with "Categorize in:"
|
||||||
|
genre = seriesDesc.first()
|
||||||
|
.children()
|
||||||
|
.find {
|
||||||
|
it.tagName().toLowerCase() == "p"
|
||||||
|
&& it.text().trim().toLowerCase() == "categorize in:"
|
||||||
|
}?.nextElementSibling()
|
||||||
|
?.text()
|
||||||
|
?.trim()
|
||||||
|
|
||||||
|
|
||||||
|
author = seriesDesc.select("div > span")?.text()?.trim()
|
||||||
|
|
||||||
|
seriesDesc?.first()?.children()?.forEach {
|
||||||
|
val keyText = it.select("p > strong").text().trim().toLowerCase()
|
||||||
|
val valueText = it.select("p > .desc").text().trim()
|
||||||
|
|
||||||
|
when (keyText) {
|
||||||
|
"artist:" -> artist = valueText
|
||||||
|
"status:" -> status = when (valueText.toLowerCase()) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"complete" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
description = seriesDesc.select("div[itemprop=description]").text()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
//This may be unreliable as Sen Manga breaks the specs by having multiple elements with the same ID
|
||||||
|
override fun chapterListSelector() = "#post > table > tbody > tr:not(.headline)"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||||
|
val linkElement = element.getElementsByTag("a")
|
||||||
|
|
||||||
|
url = linkElement.attr("href")
|
||||||
|
|
||||||
|
name = linkElement.text()
|
||||||
|
|
||||||
|
chapter_number = element.child(0).text().trim().toFloatOrNull() ?: -1f
|
||||||
|
|
||||||
|
date_upload = parseRelativeDate(element.children().last().text().trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses dates in this form:
|
||||||
|
* `11 days ago`
|
||||||
|
*/
|
||||||
|
private fun parseRelativeDate(date: String): Long {
|
||||||
|
val trimmedDate = date.split(" ")
|
||||||
|
|
||||||
|
if (trimmedDate[2] != "ago") return 0
|
||||||
|
|
||||||
|
val number = trimmedDate[0].toIntOrNull() ?: return 0
|
||||||
|
val unit = trimmedDate[1].removeSuffix("s") //Remove 's' suffix
|
||||||
|
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
|
||||||
|
//Map English unit to Java unit
|
||||||
|
val javaUnit = when (unit) {
|
||||||
|
"year" -> Calendar.YEAR
|
||||||
|
"month" -> Calendar.MONTH
|
||||||
|
"week" -> Calendar.WEEK_OF_MONTH
|
||||||
|
"day" -> Calendar.DAY_OF_MONTH
|
||||||
|
"hour" -> Calendar.HOUR
|
||||||
|
"minute" -> Calendar.MINUTE
|
||||||
|
"second" -> Calendar.SECOND
|
||||||
|
else -> return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
now.add(javaUnit, -number)
|
||||||
|
|
||||||
|
return now.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
//Base URI (document URI but without page index)
|
||||||
|
val baseUri = Uri.parse(baseUrl).buildUpon().apply {
|
||||||
|
Uri.parse(document.baseUri()).pathSegments.let {
|
||||||
|
it.take(it.size - 1)
|
||||||
|
}.forEach {
|
||||||
|
appendPath(it)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
//Base Image URI (document URI but without page index and with "viewer" inserted as first path segment
|
||||||
|
val baseImageUri = Uri.parse(baseUrl).buildUpon().appendPath("viewer").apply {
|
||||||
|
baseUri.pathSegments.forEach {
|
||||||
|
appendPath(it)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return document.select("select[name=page] > option").map {
|
||||||
|
val index = it.attr("value")
|
||||||
|
|
||||||
|
val uri = baseUri.buildUpon().appendPath(index).build()
|
||||||
|
|
||||||
|
val imageUriBuilder = baseImageUri.buildUpon().appendPath(index).build()
|
||||||
|
|
||||||
|
Page(index.toInt() - 1, uri.toString(), imageUriBuilder.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//We are able to get the image URL directly from the page list
|
||||||
|
override fun imageUrlParse(document: Document)
|
||||||
|
= throw UnsupportedOperationException("This method should not be called!")
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
Filter.Header("NOTE: Ignored if using text search!"),
|
||||||
|
GenreFilter(),
|
||||||
|
Filter.Header("NOTE: Sort ignores genres search!"),
|
||||||
|
SortFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GenreFilter : Filter.Select<String>("Genre", GENRES.map { it.second }.toTypedArray()) {
|
||||||
|
fun genrePath() = GENRES[state].first
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SortFilter : UriSelectFilter("Sort", "order", arrayOf(
|
||||||
|
Pair("popular", "Popularity"),
|
||||||
|
Pair("title", "Title"),
|
||||||
|
Pair("rating", "Rating")
|
||||||
|
), false) {
|
||||||
|
fun isDefault() = state == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that creates a select filter. Each entry in the dropdown has a name and a display name.
|
||||||
|
* If an entry is selected it is appended as a query parameter onto the end of the URI.
|
||||||
|
* If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI.
|
||||||
|
*/
|
||||||
|
//vals: <name, display>
|
||||||
|
private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>,
|
||||||
|
val firstIsUnspecified: Boolean = true,
|
||||||
|
defaultValue: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter {
|
||||||
|
override fun addToUri(uri: Uri.Builder) {
|
||||||
|
if (state != 0 || !firstIsUnspecified)
|
||||||
|
uri.appendQueryParameter(uriParam, vals[state].first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a filter that is able to modify a URI.
|
||||||
|
*/
|
||||||
|
private interface UriFilter {
|
||||||
|
fun addToUri(uri: Uri.Builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ALL_GENRES_PATH = "all"
|
||||||
|
//<path, display name>
|
||||||
|
private val GENRES = listOf(
|
||||||
|
Pair(ALL_GENRES_PATH, "All"),
|
||||||
|
Pair("Action", "Action"),
|
||||||
|
Pair("Adult", "Adult"),
|
||||||
|
Pair("Adventure", "Adventure"),
|
||||||
|
Pair("Comedy", "Comedy"),
|
||||||
|
Pair("Cooking", "Cooking"),
|
||||||
|
Pair("Drama", "Drama"),
|
||||||
|
Pair("Ecchi", "Ecchi"),
|
||||||
|
Pair("Fantasy", "Fantasy"),
|
||||||
|
Pair("Gender-Bender", "Gender Bender"),
|
||||||
|
Pair("Harem", "Harem"),
|
||||||
|
Pair("Historical", "Historical"),
|
||||||
|
Pair("Horror", "Horror"),
|
||||||
|
Pair("Josei", "Josei"),
|
||||||
|
Pair("Light_Novel", "Light Novel"),
|
||||||
|
Pair("Martial_Arts", "Martial Arts"),
|
||||||
|
Pair("Mature", "Mature"),
|
||||||
|
Pair("Music", "Music"),
|
||||||
|
Pair("Mystery", "Mystery"),
|
||||||
|
Pair("Psychological", "Psychological"),
|
||||||
|
Pair("Romance", "Romance"),
|
||||||
|
Pair("School_Life", "School Life"),
|
||||||
|
Pair("Sci-Fi", "Sci-Fi"),
|
||||||
|
Pair("Seinen", "Seinen"),
|
||||||
|
Pair("Shoujo", "Shoujo"),
|
||||||
|
Pair("Shoujo-Ai", "Shoujo Ai"),
|
||||||
|
Pair("Shounen", "Shounen"),
|
||||||
|
Pair("Shounen-Ai", "Shounen Ai"),
|
||||||
|
Pair("Slice_of_Life", "Slice of Life"),
|
||||||
|
Pair("Smut", "Smut"),
|
||||||
|
Pair("Sports", "Sports"),
|
||||||
|
Pair("Supernatural", "Supernatural"),
|
||||||
|
Pair("Tragedy", "Tragedy"),
|
||||||
|
Pair("Webtoons", "Webtoons"),
|
||||||
|
Pair("Yaoi", "Yaoi"),
|
||||||
|
Pair("Yuri", "Yuri")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user