add multi-src: GalleryAdults (#2553)
* Convert AsmHentai into multisrc GalleryAdults Also convert some selector into function * Move HentaiFox to theme GalleryAdults * GalleryAdults: Fix search * MangaFox: fix TagFilter * fast page load & preference for slowly parsing image’s URL * AsmHentai: change shortTitle reference from list to swith * HentaiFox: add Korean * move HentaiFox from en to all * fix build * fix search: convert space to + * Request for tags list from site * Fix request for user’s favorites * - Optimize popular/latest request - Improve ‘page’ param - AsmHentai: support Latest/Popular * add SortFilter * Support multiple tags filter * Support exact match query * getTime * Fix Lang when searching * fix searchById * add language companion * Fix URL action * renovate * Support parsing json for page list Fix generating page if less than 10 pages HentaiFox: Random server selection * Migrate IMHentai to GalleryAdults * Preferences to support all methods for page querying * IMHentai: tagList * Expose some filters to child class, add more space to description * Fix Factory lang * Support browsing tags, speechless & favorite * IMHentai: - support favorite browsing (require login) - tag filter with queried popular tags - advanced search for artist, group, character, parody, tag (include/exclude) - remove language filters - Fix language search * Move advance search to multi-src * Fix: hide speechless when not supported * add Hint to use comma * split code to Filters & Utils * bump version all 3 extensions * fix getTime * fix lint * Fix alternative name * improve cleanTag * move out of Object * move Regex out * remove RandomUA * fix build * remove images parsing setting, pick a default one * fix build * Move shortTitle to base clash * HentaiFox: add language keyword to search query * if all mangas in current searching page is of other language then include at least 1 entry so it can request for next page * Alternative methods for images parsing Revert "remove images parsing setting, pick a default one" This reverts commit e49e3aaeb74e3643abc2e303924da18a52491793. # Conflicts: # lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt # src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt # src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt * Fall back if failed to decode JSON * remove supportLatest from base class * Remove preference for parsing page by page, switch to override val instead. * Split searchRequest into parts * Don't using generic Filter.Text to avoid other kind of text field which extensions might have
|
@ -1,19 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.asmhentai.ASMHUrlActivity"
|
||||
android:name="eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdultsUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="asmhentai.com"
|
||||
android:pathPattern="/g/..*"
|
||||
android:scheme="https" />
|
||||
android:host="${SOURCEHOST}"
|
||||
android:pathPattern="/g.*/..*/"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
|
@ -0,0 +1,5 @@
|
|||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 0
|
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 11 KiB |
|
@ -0,0 +1,850 @@
|
|||
package eu.kanade.tachiyomi.multisrc.galleryadults
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
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.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
abstract class GalleryAdults(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "all",
|
||||
protected open val mangaLang: String = LANGUAGE_MULTI,
|
||||
protected val simpleDateFormat: SimpleDateFormat? = null,
|
||||
) : ConfigurableSource, ParsedHttpSource() {
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
protected open val xhrHeaders = headers.newBuilder()
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
protected val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
protected val SharedPreferences.shortTitle
|
||||
get() = getBoolean(PREF_SHORT_TITLE, false)
|
||||
|
||||
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_SHORT_TITLE
|
||||
title = "Display Short Titles"
|
||||
summaryOff = "Showing Long Titles"
|
||||
summaryOn = "Showing short Titles"
|
||||
setDefaultValue(false)
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
protected open fun Element.mangaTitle(selector: String = ".caption"): String? =
|
||||
mangaFullTitle(selector).let {
|
||||
if (preferences.shortTitle) it?.shortenTitle() else it
|
||||
}
|
||||
|
||||
protected fun Element.mangaFullTitle(selector: String) =
|
||||
selectFirst(selector)?.text()
|
||||
|
||||
private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim()
|
||||
|
||||
/* List detail */
|
||||
protected class SMangaDto(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val thumbnail: String?,
|
||||
val lang: String,
|
||||
)
|
||||
|
||||
protected open fun Element.mangaUrl() =
|
||||
selectFirst(".inner_thumb a")?.attr("abs:href")
|
||||
|
||||
protected open fun Element.mangaThumbnail() =
|
||||
selectFirst(".inner_thumb img")?.imgAttr()
|
||||
|
||||
// Overwrite this to filter other languages' manga from search result.
|
||||
// Default to [mangaLang] won't filter anything
|
||||
protected open fun Element.mangaLang() = mangaLang
|
||||
|
||||
protected open fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder {
|
||||
val url = toString()
|
||||
if (!url.endsWith('/') && !url.contains('?')) {
|
||||
addPathSegment("") // trailing slash (/)
|
||||
}
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
return this
|
||||
}
|
||||
|
||||
/* Popular */
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang")
|
||||
if (supportsLatest) addPathSegment("popular")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "div.thumb"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
title = element.mangaTitle()!!
|
||||
setUrlWithoutDomain(element.mangaUrl()!!)
|
||||
thumbnail_url = element.mangaThumbnail()
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = ".pagination li.active + li:not(.disabled)"
|
||||
|
||||
/* Latest */
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
/* Search */
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PREFIX_ID_SEARCH) -> {
|
||||
val id = query.removePrefix(PREFIX_ID_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaByIdParse(response, id) }
|
||||
}
|
||||
query.toIntOrNull() != null -> {
|
||||
client.newCall(searchMangaByIdRequest(query))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaByIdParse(response, query) }
|
||||
}
|
||||
else -> {
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaParse(response) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open val idPrefixUri = "g"
|
||||
|
||||
protected open fun searchMangaByIdRequest(id: String): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment(idPrefixUri)
|
||||
addPathSegments("$id/")
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
protected open fun searchMangaByIdParse(response: Response, id: String): MangasPage {
|
||||
val details = mangaDetailsParse(response.asJsoup())
|
||||
details.url = "/$idPrefixUri/$id/"
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
protected open val useIntermediateSearch: Boolean = false
|
||||
protected open val supportAdvancedSearch: Boolean = false
|
||||
protected open val supportSpeechless: Boolean = false
|
||||
private val useBasicSearch: Boolean
|
||||
get() = !useIntermediateSearch
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
val genresFilter = filters.filterIsInstance<GenresFilter>().firstOrNull()
|
||||
val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList()
|
||||
val favoriteFilter = filters.filterIsInstance<FavoriteFilter>().firstOrNull()
|
||||
|
||||
// Speechless
|
||||
val speechlessFilter = filters.filterIsInstance<SpeechlessFilter>().firstOrNull()
|
||||
|
||||
// Advanced search
|
||||
val advancedSearchFilters = filters.filterIsInstance<AdvancedTextFilter>()
|
||||
|
||||
return when {
|
||||
favoriteFilter?.state == true ->
|
||||
favoriteFilterSearchRequest(page, query, filters)
|
||||
supportSpeechless && speechlessFilter?.state == true ->
|
||||
speechlessFilterSearchRequest(page, query, filters)
|
||||
supportAdvancedSearch && advancedSearchFilters.any { it.state.isNotBlank() } ->
|
||||
advancedSearchRequest(page, query, filters)
|
||||
selectedGenres.size == 1 && query.isBlank() ->
|
||||
tagBrowsingSearchRequest(page, query, filters)
|
||||
useIntermediateSearch ->
|
||||
intermediateSearchRequest(page, query, filters)
|
||||
useBasicSearch && (selectedGenres.size > 1 || query.isNotBlank()) ->
|
||||
basicSearchRequest(page, query, filters)
|
||||
sortOrderFilter?.state == 1 ->
|
||||
latestUpdatesRequest(page)
|
||||
else ->
|
||||
popularMangaRequest(page)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsing user's personal favorites saved on site. This requires login in view WebView.
|
||||
*/
|
||||
protected open fun favoriteFilterSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/$favoritePath".toHttpUrl().newBuilder()
|
||||
return POST(
|
||||
url.build().toString(),
|
||||
xhrHeaders,
|
||||
FormBody.Builder()
|
||||
.add("page", page.toString())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsing speechless titles. Some sites exclude speechless titles from normal search and
|
||||
* allow browsing separately.
|
||||
*/
|
||||
protected open fun speechlessFilterSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("language")
|
||||
addPathSegment(LANGUAGE_SPEECHLESS)
|
||||
if (sortOrderFilter?.state == 0) addPathSegment("popular")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
protected open fun tagBrowsingSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
val genresFilter = filters.filterIsInstance<GenresFilter>().firstOrNull()
|
||||
val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList()
|
||||
|
||||
// Browsing single tag's catalog
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("tag")
|
||||
addPathSegment(selectedGenres.single().uri)
|
||||
if (sortOrderFilter?.state == 0) addPathSegment("popular")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Search: support query string with multiple-genres filter by adding genres to query string.
|
||||
*/
|
||||
protected open fun basicSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
val genresFilter = filters.filterIsInstance<GenresFilter>().firstOrNull()
|
||||
val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList()
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("search/")
|
||||
addEncodedQueryParameter("q", buildQueryString(selectedGenres.map { it.name }, query))
|
||||
// Search results sorting is not supported by AsmHentai
|
||||
if (sortOrderFilter?.state == 0) addQueryParameter("sort", "popular")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* This supports filter query search with languages, categories (manga, doujinshi...)
|
||||
* with additional sort orders.
|
||||
*/
|
||||
protected open fun intermediateSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
val genresFilter = filters.filterIsInstance<GenresFilter>().firstOrNull()
|
||||
val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList()
|
||||
|
||||
// Intermediate search
|
||||
val categoryFilters = filters.filterIsInstance<CategoryFilters>().firstOrNull()
|
||||
|
||||
// Only for query string or multiple tags
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index))
|
||||
}
|
||||
categoryFilters?.state?.forEach {
|
||||
addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
getLanguageURIs().forEach { pair ->
|
||||
addQueryParameter(
|
||||
pair.second,
|
||||
toBinary(mangaLang == pair.first || mangaLang == LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
||||
addEncodedQueryParameter("key", buildQueryString(selectedGenres.map { it.name }, query))
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Search normally won't support search for string but allow include/exclude specific
|
||||
* tags/artists/groups/parodies/characters
|
||||
*/
|
||||
protected open fun advancedSearchRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Basic search
|
||||
val sortOrderFilter = filters.filterIsInstance<SortOrderFilter>().firstOrNull()
|
||||
val genresFilter = filters.filterIsInstance<GenresFilter>().firstOrNull()
|
||||
val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList()
|
||||
|
||||
// Intermediate search
|
||||
val categoryFilters = filters.filterIsInstance<CategoryFilters>().firstOrNull()
|
||||
// Advanced search
|
||||
val advancedSearchFilters = filters.filterIsInstance<AdvancedTextFilter>()
|
||||
|
||||
val url = "$baseUrl/advsearch".toHttpUrl().newBuilder().apply {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index))
|
||||
}
|
||||
categoryFilters?.state?.forEach {
|
||||
addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
getLanguageURIs().forEach { pair ->
|
||||
addQueryParameter(
|
||||
pair.second,
|
||||
toBinary(
|
||||
mangaLang == pair.first ||
|
||||
mangaLang == LANGUAGE_MULTI,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Build this query string: +tag:"bat+man"+-tag:"cat"+artist:"Joe"...
|
||||
// +tag must be encoded into %2Btag while the rest are not needed to encode
|
||||
val keys = emptyList<String>().toMutableList()
|
||||
keys.addAll(selectedGenres.map { "%2Btag:\"${it.name}\"" })
|
||||
advancedSearchFilters.forEach { filter ->
|
||||
val key = when (filter) {
|
||||
is TagsFilter -> "tag"
|
||||
is ParodiesFilter -> "parody"
|
||||
is ArtistsFilter -> "artist"
|
||||
is CharactersFilter -> "character"
|
||||
is GroupsFilter -> "group"
|
||||
else -> null
|
||||
}
|
||||
if (key != null) {
|
||||
keys.addAll(
|
||||
filter.state.trim()
|
||||
.replace(regexSpaceNotAfterComma, "+")
|
||||
.replace(" ", "")
|
||||
.split(',')
|
||||
.mapNotNull {
|
||||
val match = regexExcludeTerm.find(it)
|
||||
match?.groupValues?.let { groups ->
|
||||
"${if (groups[1].isNotBlank()) "-" else "%2B"}$key:\"${groups[2]}\""
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
addEncodedQueryParameter("key", keys.joinToString("+"))
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert space( ) typed in search-box into plus(+) in URL. Then:
|
||||
* - uses plus(+) to search for exact match
|
||||
* - use comma(,) for separate terms, as AND condition.
|
||||
* Plus(+) after comma(,) doesn't have any effect.
|
||||
*/
|
||||
protected open fun buildQueryString(tags: List<String>, query: String): String {
|
||||
return (tags + query).filterNot { it.isBlank() }.joinToString(",") {
|
||||
// any space except after a comma (we're going to replace spaces only between words)
|
||||
it.trim()
|
||||
.replace(regexSpaceNotAfterComma, "+")
|
||||
.replace(" ", "")
|
||||
}
|
||||
}
|
||||
|
||||
protected open val favoritePath = "includes/user_favs.php"
|
||||
|
||||
protected open fun loginRequired(document: Document, url: String): Boolean {
|
||||
return (
|
||||
url.contains("/login/") &&
|
||||
document.select("input[value=Login]").isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (loginRequired(document, response.request.url.toString())) {
|
||||
throw Exception("Log in via WebView to view favorites")
|
||||
} else {
|
||||
val hasNextPage = document.select(searchMangaNextPageSelector()).isNotEmpty()
|
||||
val mangas = document.select(searchMangaSelector())
|
||||
.map {
|
||||
SMangaDto(
|
||||
title = it.mangaTitle()!!,
|
||||
url = it.mangaUrl()!!,
|
||||
thumbnail = it.mangaThumbnail(),
|
||||
lang = it.mangaLang(),
|
||||
)
|
||||
}
|
||||
.let { unfiltered ->
|
||||
val results = unfiltered.filter { mangaLang.isBlank() || it.lang == mangaLang }
|
||||
// return at least 1 title if all mangas in current page is of other languages
|
||||
if (results.isEmpty() && hasNextPage) listOf(unfiltered[0]) else results
|
||||
}
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
title = it.title
|
||||
setUrlWithoutDomain(it.url)
|
||||
thumbnail_url = it.thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
/* Details */
|
||||
protected open fun Element.getCover() =
|
||||
selectFirst(".cover img")?.imgAttr()
|
||||
|
||||
protected open fun Element.getInfo(tag: String): String {
|
||||
return select("ul.${tag.lowercase()} a")
|
||||
.joinToString { it.ownText() }
|
||||
}
|
||||
|
||||
protected open fun Element.getDescription(): String = (
|
||||
listOf("Parodies", "Characters", "Languages", "Categories")
|
||||
.mapNotNull { tag ->
|
||||
getInfo(tag)
|
||||
.let { if (it.isNotBlank()) "$tag: $it" else null }
|
||||
} +
|
||||
listOfNotNull(
|
||||
selectFirst(".pages:contains(Pages:)")?.ownText(),
|
||||
)
|
||||
)
|
||||
.joinToString("\n\n")
|
||||
|
||||
protected open val mangaDetailInfoSelector = ".gallery_top"
|
||||
protected open val timeSelector = "time[datetime]"
|
||||
|
||||
protected open fun Element.getTime(): Long {
|
||||
return selectFirst(timeSelector)
|
||||
?.attr("datetime")
|
||||
.toDate(simpleDateFormat)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
return document.selectFirst(mangaDetailInfoSelector)!!.run {
|
||||
SManga.create().apply {
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
status = SManga.COMPLETED
|
||||
title = mangaTitle("h1")!!
|
||||
thumbnail_url = getCover()
|
||||
genre = getInfo("Tags")
|
||||
author = getInfo("Artists")
|
||||
description = getDescription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Chapters */
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
scanlator = document.selectFirst(mangaDetailInfoSelector)
|
||||
?.getInfo("Groups")
|
||||
date_upload = document.getTime()
|
||||
setUrlWithoutDomain(response.request.url.encodedPath)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
/* Pages */
|
||||
protected open fun Document.inputIdValueOf(string: String): String {
|
||||
return select("input[id=$string]").attr("value")
|
||||
}
|
||||
|
||||
protected open val galleryIdSelector = "gallery_id"
|
||||
protected open val loadIdSelector = "load_id"
|
||||
protected open val loadDirSelector = "load_dir"
|
||||
protected open val totalPagesSelector = "load_pages"
|
||||
protected open val pageUri = "g"
|
||||
protected open val pageSelector = ".gallery_thumb"
|
||||
|
||||
protected open val pagesRequest = "inc/thumbs_loader.php"
|
||||
|
||||
private val jsonFormat: Json by injectLazy()
|
||||
|
||||
protected open fun getServer(document: Document, galleryId: String): String {
|
||||
val cover = document.getCover()
|
||||
return cover!!.toHttpUrl().host
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val json = document.selectFirst("script:containsData(parseJSON)")?.data()
|
||||
?.substringAfter("$.parseJSON('")
|
||||
?.substringBefore("');")?.trim()
|
||||
|
||||
if (json != null) {
|
||||
val loadDir = document.inputIdValueOf(loadDirSelector)
|
||||
val loadId = document.inputIdValueOf(loadIdSelector)
|
||||
val galleryId = document.inputIdValueOf(galleryIdSelector)
|
||||
val pageUrl = "$baseUrl/$pageUri/$galleryId"
|
||||
|
||||
val randomServer = getServer(document, galleryId)
|
||||
val imagesUri = "https://$randomServer/$loadDir/$loadId"
|
||||
|
||||
try {
|
||||
val pages = mutableListOf<Page>()
|
||||
val images = jsonFormat.parseToJsonElement(json).jsonObject
|
||||
|
||||
// JSON string in this form: {"1":"j,1100,1148","2":"j,728,689",...
|
||||
for (image in images) {
|
||||
val ext = image.value.toString().replace("\"", "").split(",")[0]
|
||||
val imageExt = when (ext) {
|
||||
"p" -> "png"
|
||||
"b" -> "bmp"
|
||||
"g" -> "gif"
|
||||
else -> "jpg"
|
||||
}
|
||||
val idx = image.key.toInt()
|
||||
pages.add(
|
||||
Page(
|
||||
index = idx,
|
||||
imageUrl = "$imagesUri/${image.key}.$imageExt",
|
||||
url = "$pageUrl/$idx/",
|
||||
),
|
||||
)
|
||||
}
|
||||
return pages
|
||||
} catch (e: SerializationException) {
|
||||
Log.e("GalleryAdults", "Failed to decode JSON")
|
||||
return this.pageListParseAlternative(document)
|
||||
}
|
||||
} else {
|
||||
return this.pageListParseAlternative(document)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite this to force extension not blindly converting thumbnails to full image
|
||||
* with simply removing the trailing "t" from file name. Instead, it will open each page,
|
||||
* one by one, then parsing for actual image's URL.
|
||||
* This will be much slower but guaranteed work.
|
||||
*/
|
||||
protected open val parsingImagePageByPage: Boolean = false
|
||||
|
||||
/**
|
||||
* Either:
|
||||
* - Load all thumbnails then convert thumbnails to full images.
|
||||
* - Or request then parse for a list of manga's page's URL,
|
||||
* which will then request one by one to parse for page's image's URL using [imageUrlParse].
|
||||
*/
|
||||
protected open fun pageListParseAlternative(document: Document): List<Page> {
|
||||
// input only exists if pages > 10 and have to make a request to get the other thumbnails
|
||||
val totalPages = document.inputIdValueOf(totalPagesSelector)
|
||||
val galleryId = document.inputIdValueOf(galleryIdSelector)
|
||||
val pageUrl = "$baseUrl/$pageUri/$galleryId"
|
||||
|
||||
val pages = document.select("$pageSelector a")
|
||||
.map {
|
||||
if (parsingImagePageByPage) {
|
||||
it.absUrl("href")
|
||||
} else {
|
||||
it.selectFirst("img")!!.imgAttr()
|
||||
}
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
if (totalPages.isNotBlank()) {
|
||||
val form = pageRequestForm(document, totalPages)
|
||||
|
||||
val morePages = client.newCall(POST("$baseUrl/$pagesRequest", xhrHeaders, form))
|
||||
.execute()
|
||||
.asJsoup()
|
||||
.select("a")
|
||||
.map {
|
||||
if (parsingImagePageByPage) {
|
||||
it.absUrl("href")
|
||||
} else {
|
||||
it.selectFirst("img")!!.imgAttr()
|
||||
}
|
||||
}
|
||||
if (morePages.isNotEmpty()) {
|
||||
pages.addAll(morePages)
|
||||
} else {
|
||||
return pageListParseDummy(document)
|
||||
}
|
||||
}
|
||||
|
||||
return pages.mapIndexed { idx, url ->
|
||||
if (parsingImagePageByPage) {
|
||||
Page(idx, url)
|
||||
} else {
|
||||
Page(
|
||||
index = idx,
|
||||
imageUrl = url.thumbnailToFull(),
|
||||
url = "$pageUrl/$idx/",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all images using `totalPages`. Supposedly they are sequential.
|
||||
* Use in case any extension doesn't know how to request for "All thumbnails"
|
||||
*/
|
||||
protected open fun pageListParseDummy(document: Document): List<Page> {
|
||||
val loadDir = document.inputIdValueOf(loadDirSelector)
|
||||
val loadId = document.inputIdValueOf(loadIdSelector)
|
||||
val galleryId = document.inputIdValueOf(galleryIdSelector)
|
||||
val pageUrl = "$baseUrl/$pageUri/$galleryId"
|
||||
|
||||
val randomServer = getServer(document, galleryId)
|
||||
val imagesUri = "https://$randomServer/$loadDir/$loadId"
|
||||
|
||||
val images = document.select("$pageSelector img")
|
||||
val thumbUrls = images.map { it.imgAttr() }.toMutableList()
|
||||
|
||||
// totalPages only exists if pages > 10 and have to make a request to get the other thumbnails
|
||||
val totalPages = document.inputIdValueOf(totalPagesSelector)
|
||||
|
||||
if (totalPages.isNotBlank()) {
|
||||
val imagesExt = images.first()?.imgAttr()!!
|
||||
.substringAfterLast('.')
|
||||
|
||||
thumbUrls.addAll(
|
||||
listOf((images.size + 1)..totalPages.toInt()).flatten().map {
|
||||
"$imagesUri/${it}t.$imagesExt"
|
||||
},
|
||||
)
|
||||
}
|
||||
return thumbUrls.mapIndexed { idx, url ->
|
||||
Page(
|
||||
index = idx,
|
||||
imageUrl = url.thumbnailToFull(),
|
||||
url = "$pageUrl/$idx/",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun pageRequestForm(document: Document, totalPages: String): FormBody =
|
||||
FormBody.Builder()
|
||||
.add("u_id", document.inputIdValueOf(galleryIdSelector))
|
||||
.add("g_id", document.inputIdValueOf(loadIdSelector))
|
||||
.add("img_dir", document.inputIdValueOf(loadDirSelector))
|
||||
.add("visible_pages", "10")
|
||||
.add("total_pages", totalPages)
|
||||
.add("type", "2") // 1 would be "more", 2 is "all remaining"
|
||||
.build()
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
return document.selectFirst("img#gimg, img#fimg")?.imgAttr()!!
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private fun launchIO(block: () -> Unit) = scope.launch { block() }
|
||||
private var tagsFetchAttempt = 0
|
||||
private var genres = emptyList<Genre>()
|
||||
|
||||
private fun tagsRequest(page: Int): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("tags/popular")
|
||||
addPageUri(page)
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
protected open fun tagsParser(document: Document): List<Pair<String, String>> {
|
||||
return document.select(".list_tags .tag_item")
|
||||
.mapNotNull {
|
||||
Pair(
|
||||
it.selectFirst("h3.list_tag")?.ownText() ?: "",
|
||||
it.select("a").attr("href")
|
||||
.removeSuffix("/").substringAfterLast('/'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGenres() {
|
||||
if (genres.isEmpty() && tagsFetchAttempt < 3) {
|
||||
launchIO {
|
||||
val tags = mutableListOf<Pair<String, String>>()
|
||||
runBlocking {
|
||||
val jobsPool = mutableListOf<Job>()
|
||||
// Get first 3 pages
|
||||
(1..3).forEach { page ->
|
||||
jobsPool.add(
|
||||
launchIO {
|
||||
runCatching {
|
||||
tags.addAll(
|
||||
client.newCall(tagsRequest(page))
|
||||
.execute().asJsoup().let { tagsParser(it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
jobsPool.joinAll()
|
||||
genres = tags.sortedWith(compareBy { it.first }).map { Genre(it.first, it.second) }
|
||||
}
|
||||
|
||||
tagsFetchAttempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
getGenres()
|
||||
val filters = emptyList<Filter<*>>().toMutableList()
|
||||
if (useIntermediateSearch) {
|
||||
filters.add(Filter.Header("HINT: Separate search term with comma (,)"))
|
||||
}
|
||||
|
||||
filters.add(SortOrderFilter(getSortOrderURIs()))
|
||||
|
||||
if (genres.isEmpty()) {
|
||||
filters.add(Filter.Header("Press 'reset' to attempt to load tags"))
|
||||
} else {
|
||||
filters.add(GenresFilter(genres))
|
||||
}
|
||||
|
||||
if (useIntermediateSearch) {
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
CategoryFilters(getCategoryURIs()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (supportAdvancedSearch) {
|
||||
filters.addAll(
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Advanced filters will ignore query search. Separate terms by comma (,) and precede term with minus (-) to exclude."),
|
||||
TagsFilter(),
|
||||
ParodiesFilter(),
|
||||
ArtistsFilter(),
|
||||
CharactersFilter(),
|
||||
GroupsFilter(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
filters.add(Filter.Separator())
|
||||
|
||||
if (supportSpeechless) {
|
||||
filters.add(SpeechlessFilter())
|
||||
}
|
||||
filters.add(FavoriteFilter())
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
protected open fun getSortOrderURIs() = listOf(
|
||||
Pair("Popular", "pp"),
|
||||
Pair("Latest", "lt"),
|
||||
) + if (useIntermediateSearch || supportAdvancedSearch) {
|
||||
listOf(
|
||||
Pair("Downloads", "dl"),
|
||||
Pair("Top Rated", "tr"),
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
protected open fun getCategoryURIs() = listOf(
|
||||
SearchFlagFilter("Manga", "m"),
|
||||
SearchFlagFilter("Doujinshi", "d"),
|
||||
SearchFlagFilter("Western", "w"),
|
||||
SearchFlagFilter("Image Set", "i"),
|
||||
SearchFlagFilter("Artist CG", "a"),
|
||||
SearchFlagFilter("Game CG", "g"),
|
||||
)
|
||||
|
||||
protected open fun getLanguageURIs() = listOf(
|
||||
Pair(LANGUAGE_ENGLISH, "en"),
|
||||
Pair(LANGUAGE_JAPANESE, "jp"),
|
||||
Pair(LANGUAGE_SPANISH, "es"),
|
||||
Pair(LANGUAGE_FRENCH, "fr"),
|
||||
Pair(LANGUAGE_KOREAN, "kr"),
|
||||
Pair(LANGUAGE_GERMAN, "de"),
|
||||
Pair(LANGUAGE_RUSSIAN, "ru"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
|
||||
private const val PREF_SHORT_TITLE = "pref_short_title"
|
||||
|
||||
// references to be used in factory
|
||||
const val LANGUAGE_MULTI = ""
|
||||
const val LANGUAGE_ENGLISH = "english"
|
||||
const val LANGUAGE_JAPANESE = "japanese"
|
||||
const val LANGUAGE_CHINESE = "chinese"
|
||||
const val LANGUAGE_KOREAN = "korean"
|
||||
const val LANGUAGE_SPANISH = "spanish"
|
||||
const val LANGUAGE_FRENCH = "french"
|
||||
const val LANGUAGE_GERMAN = "german"
|
||||
const val LANGUAGE_RUSSIAN = "russian"
|
||||
const val LANGUAGE_SPEECHLESS = "speechless"
|
||||
const val LANGUAGE_TRANSLATED = "translated"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package eu.kanade.tachiyomi.multisrc.galleryadults
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
class Genre(name: String, val uri: String) : Filter.CheckBox(name)
|
||||
class GenresFilter(genres: List<Genre>) : Filter.Group<Genre>(
|
||||
"Tags",
|
||||
genres.map { Genre(it.name, it.uri) },
|
||||
)
|
||||
|
||||
class SortOrderFilter(sortOrderURIs: List<Pair<String, String>>) :
|
||||
Filter.Select<String>("Sort By", sortOrderURIs.map { it.first }.toTypedArray())
|
||||
|
||||
class FavoriteFilter : Filter.CheckBox("Show favorites only (login via WebView)", false)
|
||||
|
||||
// Speechless
|
||||
class SpeechlessFilter : Filter.CheckBox("Show speechless items only", false)
|
||||
|
||||
// Intermediate search
|
||||
class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state)
|
||||
class CategoryFilters(flags: List<SearchFlagFilter>) : Filter.Group<SearchFlagFilter>("Categories", flags)
|
||||
|
||||
// Advance search
|
||||
abstract class AdvancedTextFilter(name: String) : Filter.Text(name)
|
||||
class TagsFilter : AdvancedTextFilter("Tags")
|
||||
class ParodiesFilter : AdvancedTextFilter("Parodies")
|
||||
class ArtistsFilter : AdvancedTextFilter("Artists")
|
||||
class CharactersFilter : AdvancedTextFilter("Characters")
|
||||
class GroupsFilter : AdvancedTextFilter("Groups")
|
|
@ -1,4 +1,4 @@
|
|||
package eu.kanade.tachiyomi.extension.all.asmhentai
|
||||
package eu.kanade.tachiyomi.multisrc.galleryadults
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
|
@ -8,27 +8,27 @@ import android.util.Log
|
|||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Springboard that accepts https://asmhentai.com/g/xxxxxx intents and redirects them to
|
||||
* the main Tachiyomi process.
|
||||
* Springboard that accepts https://<domain>/g/xxxxxx intents and redirects them to main app process.
|
||||
*/
|
||||
class ASMHUrlActivity : Activity() {
|
||||
class GalleryAdultsUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = pathSegments[1]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${AsmHentai.PREFIX_ID_SEARCH}${pathSegments[1]}")
|
||||
putExtra("query", "${GalleryAdults.PREFIX_ID_SEARCH}$id")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("ASMHUrlActivity", e.toString())
|
||||
Log.e("GalleryAdultsUrl", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("ASMHUrlActivity", "could not parse uri from intent $intent")
|
||||
Log.e("GalleryAdultsUrl", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
|
@ -0,0 +1,144 @@
|
|||
package eu.kanade.tachiyomi.multisrc.galleryadults
|
||||
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
|
||||
// any space except after a comma (we're going to replace spaces only between words)
|
||||
val regexSpaceNotAfterComma = Regex("""(?<!,)\s+""")
|
||||
|
||||
// extract preceding minus (-) and term
|
||||
val regexExcludeTerm = Regex("""^(-?)"?(.+)"?""")
|
||||
|
||||
val regexTagCountNumber = Regex("\\([0-9,]*\\)")
|
||||
val regexDateSuffix = Regex("""\d(st|nd|rd|th)""")
|
||||
val regexDate = Regex("""\d\D\D""")
|
||||
val regexNotNumber = Regex("""\D""")
|
||||
val regexRelativeDateTime = Regex("""\d*[^0-9]*(\d+)""")
|
||||
|
||||
fun Element.imgAttr() = when {
|
||||
hasAttr("data-cfsrc") -> absUrl("data-cfsrc")
|
||||
hasAttr("data-src") -> absUrl("data-src")
|
||||
hasAttr("data-lazy-src") -> absUrl("data-lazy-src")
|
||||
hasAttr("srcset") -> absUrl("srcset").substringBefore(" ")
|
||||
else -> absUrl("src")
|
||||
}!!
|
||||
|
||||
fun Element.cleanTag(): String = text().cleanTag()
|
||||
fun String.cleanTag(): String = replace(regexTagCountNumber, "").trim()
|
||||
|
||||
// convert thumbnail URLs to full image URLs
|
||||
fun String.thumbnailToFull(): String {
|
||||
val ext = substringAfterLast(".")
|
||||
return replace("t.$ext", ".$ext")
|
||||
}
|
||||
|
||||
fun String?.toDate(simpleDateFormat: SimpleDateFormat?): Long {
|
||||
this ?: return 0L
|
||||
|
||||
return if (simpleDateFormat != null) {
|
||||
if (contains(regexDateSuffix)) {
|
||||
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
|
||||
split(" ").map {
|
||||
if (it.contains(regexDate)) {
|
||||
it.replace(regexNotNumber, "")
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.let { simpleDateFormat.tryParse(it.joinToString(" ")) }
|
||||
} else {
|
||||
simpleDateFormat.tryParse(this)
|
||||
}
|
||||
} else {
|
||||
parseDate(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDate(date: String?): Long {
|
||||
date ?: return 0
|
||||
|
||||
return when {
|
||||
// Handle 'yesterday' and 'today', using midnight
|
||||
WordSet("yesterday", "يوم واحد").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("today", "just now").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("يومين").startsWith(date) -> {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, -2) // day before yesterday
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
WordSet("hace").startsWith(date) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
// Parses dates in this form: 21 hours ago OR "2 days ago (Updated 19 hours ago)"
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val number = regexRelativeDateTime.find(date)?.value?.toIntOrNull()
|
||||
?: date.split(" ").firstOrNull()
|
||||
?.replace("one", "1")
|
||||
?.replace("a", "1")
|
||||
?.toIntOrNull()
|
||||
?: return 0L
|
||||
val now = Calendar.getInstance()
|
||||
|
||||
// Sort by order
|
||||
return when {
|
||||
WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "天").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||
WordSet("week", "semana").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
|
||||
WordSet("month", "mes").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||
WordSet("year", "año").anyWordIn(date) ->
|
||||
now.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun SimpleDateFormat.tryParse(string: String): Long {
|
||||
return try {
|
||||
parse(string)?.time ?: 0L
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
class WordSet(private vararg val words: String) {
|
||||
fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
|
||||
fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) }
|
||||
fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
fun toBinary(boolean: Boolean) = if (boolean) "1" else "0"
|
|
@ -1,7 +1,9 @@
|
|||
ext {
|
||||
extName = 'AsmHentai'
|
||||
extClass = '.ASMHFactory'
|
||||
extVersionCode = 1
|
||||
themePkg = 'galleryadults'
|
||||
baseUrl = 'https://asmhentai.com'
|
||||
overrideVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
package eu.kanade.tachiyomi.extension.all.asmhentai
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class ASMHFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
AsmHentai("en", "english"),
|
||||
AsmHentai("ja", "japanese"),
|
||||
AsmHentai("zh", "chinese"),
|
||||
AsmHentai("all", ""),
|
||||
AsmHentai("en", GalleryAdults.LANGUAGE_ENGLISH),
|
||||
AsmHentai("ja", GalleryAdults.LANGUAGE_JAPANESE),
|
||||
AsmHentai("zh", GalleryAdults.LANGUAGE_CHINESE),
|
||||
AsmHentai("all", GalleryAdults.LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,274 +1,102 @@
|
|||
package eu.kanade.tachiyomi.extension.all.asmhentai
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr
|
||||
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.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
open class AsmHentai(override val lang: String, private val tlTag: String) : ParsedHttpSource() {
|
||||
class AsmHentai(
|
||||
lang: String = "all",
|
||||
override val mangaLang: String = LANGUAGE_MULTI,
|
||||
) : GalleryAdults(
|
||||
"AsmHentai",
|
||||
"https://asmhentai.com",
|
||||
lang = lang,
|
||||
) {
|
||||
override val supportsLatest = mangaLang.isNotBlank()
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
override fun Element.mangaUrl() =
|
||||
selectFirst(".image a")?.attr("abs:href")
|
||||
|
||||
override val baseUrl = "https://asmhentai.com"
|
||||
override fun Element.mangaThumbnail() =
|
||||
selectFirst(".image img")?.imgAttr()
|
||||
|
||||
override val name = "AsmHentai"
|
||||
override fun Element.mangaLang() =
|
||||
select("a:has(.flag)").attr("href")
|
||||
.removeSuffix("/").substringAfterLast("/")
|
||||
|
||||
override val supportsLatest = false
|
||||
override fun popularMangaSelector() = ".preview_item"
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (tlTag.isNotEmpty()) addPathSegments("language/$tlTag/")
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
override fun Element.getInfo(tag: String): String {
|
||||
return select(".tags:contains($tag:) .tag")
|
||||
.joinToString { it.ownText().cleanTag() }
|
||||
}
|
||||
|
||||
override fun popularMangaSelector(): String = ".preview_item"
|
||||
|
||||
private fun Element.mangaTitle() = select("h2").text()
|
||||
|
||||
private fun Element.mangaUrl() = select(".image a").attr("abs:href")
|
||||
|
||||
private fun Element.mangaThumbnail() = select(".image img").attr("abs:src")
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
title = element.mangaTitle()
|
||||
setUrlWithoutDomain(element.mangaUrl())
|
||||
thumbnail_url = element.mangaThumbnail()
|
||||
}
|
||||
override fun Element.getDescription(): String {
|
||||
return (
|
||||
listOf("Parodies", "Characters", "Languages", "Category")
|
||||
.mapNotNull { tag ->
|
||||
getInfo(tag)
|
||||
.let { if (it.isNotBlank()) "$tag: $it" else null }
|
||||
} +
|
||||
listOfNotNull(
|
||||
selectFirst(".book_page .pages h3")?.ownText(),
|
||||
selectFirst(".book_page h1 + h2")?.ownText()
|
||||
.let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null },
|
||||
)
|
||||
)
|
||||
.joinToString("\n\n")
|
||||
.plus(
|
||||
if (preferences.shortTitle) {
|
||||
"\nFull title: ${mangaFullTitle("h1")}"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String = "li.active + li:not(.disabled)"
|
||||
/* Search */
|
||||
override val favoritePath = "inc/user.php?act=favs"
|
||||
|
||||
// Latest
|
||||
override val mangaDetailInfoSelector = ".book_page"
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? {
|
||||
throw UnsupportedOperationException()
|
||||
override val galleryIdSelector = "load_id"
|
||||
override val totalPagesSelector = "t_pages"
|
||||
override val pageUri = "gallery"
|
||||
override val pageSelector = ".preview_thumb"
|
||||
|
||||
override fun pageRequestForm(document: Document, totalPages: String): FormBody {
|
||||
val token = document.select("[name=csrf-token]").attr("content")
|
||||
|
||||
return FormBody.Builder()
|
||||
.add("_token", token)
|
||||
.add("id", document.inputIdValueOf(loadIdSelector))
|
||||
.add("dir", document.inputIdValueOf(loadDirSelector))
|
||||
.add("visible_pages", "10")
|
||||
.add("t_pages", totalPages)
|
||||
.add("type", "2") // 1 would be "more", 2 is "all remaining"
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector(): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PREFIX_ID_SEARCH) -> {
|
||||
val id = query.removePrefix(PREFIX_ID_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(id))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaByIdParse(response, id) }
|
||||
}
|
||||
query.toIntOrNull() != null -> {
|
||||
client.newCall(searchMangaByIdRequest(query))
|
||||
.asObservableSuccess()
|
||||
.map { response -> searchMangaByIdParse(response, query) }
|
||||
}
|
||||
else -> super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
// any space except after a comma (we're going to replace spaces only between words)
|
||||
private val spaceRegex = Regex("""(?<!,)\s+""")
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val tags = (filters.last() as TagFilter).state
|
||||
|
||||
val q = when {
|
||||
tags.isBlank() -> query
|
||||
query.isBlank() -> tags
|
||||
else -> "$query,$tags"
|
||||
}.replace(spaceRegex, "+")
|
||||
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegments("search/")
|
||||
addEncodedQueryParameter("q", q)
|
||||
if (page > 1) addQueryParameter("page", page.toString())
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
private class SMangaDto(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val thumbnail: String,
|
||||
val lang: String,
|
||||
)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
|
||||
val mangas = doc.select(searchMangaSelector())
|
||||
.map {
|
||||
SMangaDto(
|
||||
title = it.mangaTitle(),
|
||||
url = it.mangaUrl(),
|
||||
thumbnail = it.mangaThumbnail(),
|
||||
lang = it.select("a:has(.flag)").attr("href").removeSuffix("/").substringAfterLast("/"),
|
||||
/* Filters */
|
||||
override fun tagsParser(document: Document): List<Pair<String, String>> {
|
||||
return document.select(".tags_page ul.tags li")
|
||||
.mapNotNull {
|
||||
Pair(
|
||||
it.selectFirst("a.tag")?.ownText() ?: "",
|
||||
it.select("a.tag").attr("href")
|
||||
.removeSuffix("/").substringAfterLast('/'),
|
||||
)
|
||||
}
|
||||
.let { unfiltered ->
|
||||
if (tlTag.isNotEmpty()) unfiltered.filter { it.lang == tlTag } else unfiltered
|
||||
}
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
title = it.title
|
||||
setUrlWithoutDomain(it.url)
|
||||
thumbnail_url = it.thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
return MangasPage(mangas, doc.select(searchMangaNextPageSelector()).isNotEmpty())
|
||||
}
|
||||
|
||||
private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/g/$id/", headers)
|
||||
|
||||
private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/g/$id/"
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// Details
|
||||
|
||||
private fun Element.get(tag: String): String {
|
||||
return select(".tags:contains($tag) .tag").joinToString { it.ownText() }
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
return SManga.create().apply {
|
||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||
document.select(".book_page").first()!!.let { element ->
|
||||
thumbnail_url = element.select(".cover img").attr("abs:src")
|
||||
title = element.select("h1").text()
|
||||
genre = element.get("Tags")
|
||||
artist = element.get("Artists")
|
||||
author = artist
|
||||
description = listOf("Parodies", "Groups", "Languages", "Category")
|
||||
.mapNotNull { tag ->
|
||||
element.get(tag).let { if (it.isNotEmpty()) "$tag: $it" else null }
|
||||
}
|
||||
.joinToString("\n", postfix = "\n") +
|
||||
element.select(".pages h3").text() +
|
||||
element.select("h1 + h2").text()
|
||||
.let { altTitle -> if (altTitle.isNotEmpty()) "\nAlternate Title: $altTitle" else "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.just(
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
url = manga.url
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// Pages
|
||||
|
||||
// convert thumbnail URLs to full image URLs
|
||||
private fun String.full(): String {
|
||||
val fType = substringAfterLast("t")
|
||||
return replace("t$fType", fType)
|
||||
}
|
||||
|
||||
private fun Document.inputIdValueOf(string: String): String {
|
||||
return select("input[id=$string]").attr("value")
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val thumbUrls = document.select(".preview_thumb img")
|
||||
.map { it.attr("abs:data-src") }
|
||||
.toMutableList()
|
||||
|
||||
// input only exists if pages > 10 and have to make a request to get the other thumbnails
|
||||
val totalPages = document.inputIdValueOf("t_pages")
|
||||
|
||||
if (totalPages.isNotEmpty()) {
|
||||
val token = document.select("[name=csrf-token]").attr("content")
|
||||
|
||||
val form = FormBody.Builder()
|
||||
.add("_token", token)
|
||||
.add("id", document.inputIdValueOf("load_id"))
|
||||
.add("dir", document.inputIdValueOf("load_dir"))
|
||||
.add("visible_pages", "10")
|
||||
.add("t_pages", totalPages)
|
||||
.add("type", "2") // 1 would be "more", 2 is "all remaining"
|
||||
.build()
|
||||
|
||||
val xhrHeaders = headers.newBuilder()
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
client.newCall(POST("$baseUrl/inc/thumbs_loader.php", xhrHeaders, form))
|
||||
.execute()
|
||||
.asJsoup()
|
||||
.select("img")
|
||||
.mapTo(thumbUrls) { it.attr("abs:data-src") }
|
||||
}
|
||||
return thumbUrls.mapIndexed { i, url -> Page(i, "", url.full()) }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Filter.Header("Separate tags with commas (,)"),
|
||||
TagFilter(),
|
||||
override fun getFilterList() = FilterList(
|
||||
listOf(
|
||||
Filter.Header("HINT: Separate search term with comma (,)"),
|
||||
) + super.getFilterList().list,
|
||||
)
|
||||
|
||||
class TagFilter : Filter.Text("Tags")
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
ext {
|
||||
extName = 'HentaiFox'
|
||||
extClass = '.HentaiFoxFactory'
|
||||
themePkg = 'galleryadults'
|
||||
baseUrl = 'https://hentaifox.com'
|
||||
overrideVersionCode = 6
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
@ -0,0 +1,97 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentaifox
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.toDate
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import kotlin.random.Random
|
||||
|
||||
class HentaiFox(
|
||||
lang: String = "all",
|
||||
override val mangaLang: String = LANGUAGE_MULTI,
|
||||
) : GalleryAdults(
|
||||
"HentaiFox",
|
||||
"https://hentaifox.com",
|
||||
lang = lang,
|
||||
mangaLang = mangaLang,
|
||||
simpleDateFormat = null,
|
||||
) {
|
||||
override val supportsLatest = mangaLang.isNotBlank()
|
||||
|
||||
private val languages: List<Pair<String, String>> = listOf(
|
||||
Pair(LANGUAGE_ENGLISH, "1"),
|
||||
Pair(LANGUAGE_TRANSLATED, "2"),
|
||||
Pair(LANGUAGE_JAPANESE, "5"),
|
||||
Pair(LANGUAGE_CHINESE, "6"),
|
||||
Pair(LANGUAGE_KOREAN, "11"),
|
||||
)
|
||||
private val langCode = languages.firstOrNull { lang -> lang.first == mangaLang }?.second
|
||||
|
||||
override fun Element.mangaLang() = attr("data-languages")
|
||||
.split(' ').let {
|
||||
when {
|
||||
it.contains(langCode) -> mangaLang
|
||||
// search result doesn't have "data-languages" which will return a list with 1 blank element
|
||||
it.size > 1 || (it.size == 1 && it.first().isNotBlank()) -> "other"
|
||||
// if we don't know which language to filter then set to mangaLang to not filter at all
|
||||
else -> mangaLang
|
||||
}
|
||||
}
|
||||
|
||||
override fun Element.mangaTitle(selector: String): String? = mangaFullTitle(selector)
|
||||
|
||||
override fun Element.getTime(): Long {
|
||||
return selectFirst(".pages:contains(Posted:)")?.ownText()
|
||||
?.removePrefix("Posted: ")
|
||||
.toDate(simpleDateFormat)
|
||||
}
|
||||
|
||||
override fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder {
|
||||
val url = toString()
|
||||
when {
|
||||
url == "$baseUrl/" && page == 2 ->
|
||||
addPathSegments("page/$page")
|
||||
url.contains('?') ->
|
||||
addQueryParameter("page", page.toString())
|
||||
page > 1 ->
|
||||
addPathSegments("pag/$page")
|
||||
}
|
||||
addPathSegment("") // trailing slash (/)
|
||||
return this
|
||||
}
|
||||
|
||||
/* Pages */
|
||||
override val pagesRequest = "includes/thumbs_loader.php"
|
||||
|
||||
override fun getServer(document: Document, galleryId: String): String {
|
||||
val domain = baseUrl.toHttpUrl().host
|
||||
// Randomly choose between servers
|
||||
return if (Random.nextBoolean()) "i2.$domain" else "i.$domain"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert space( ) typed in search-box into plus(+) in URL. Then:
|
||||
* - ignore the word preceding by a special character (e.g. 'school-girl' will ignore 'girl')
|
||||
* => replace to plus(+),
|
||||
* - use plus(+) for separate terms, as AND condition.
|
||||
* - use double quote(") to search for exact match.
|
||||
*/
|
||||
override fun buildQueryString(tags: List<String>, query: String): String {
|
||||
val regexSpecialCharacters = Regex("""[^a-zA-Z0-9"]+(?=[a-zA-Z0-9"])""")
|
||||
return (tags + query + mangaLang).filterNot { it.isBlank() }.joinToString("+") {
|
||||
it.trim().replace(regexSpecialCharacters, "+")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
listOf(
|
||||
Filter.Header("HINT: Use double quote (\") for exact match"),
|
||||
) + super.getFilterList().list,
|
||||
)
|
||||
|
||||
override val idPrefixUri = "gallery"
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hentaifox
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class HentaiFoxFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
HentaiFox("en", GalleryAdults.LANGUAGE_ENGLISH),
|
||||
HentaiFox("ja", GalleryAdults.LANGUAGE_JAPANESE),
|
||||
HentaiFox("zh", GalleryAdults.LANGUAGE_CHINESE),
|
||||
HentaiFox("ko", GalleryAdults.LANGUAGE_KOREAN),
|
||||
HentaiFox("all", GalleryAdults.LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
ext {
|
||||
extName = 'IMHentai'
|
||||
extClass = '.IMHentaiFactory'
|
||||
extVersionCode = 14
|
||||
themePkg = 'galleryadults'
|
||||
baseUrl = 'https://imhentai.xxx'
|
||||
overrideVersionCode = 15
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,37 @@
|
|||
package eu.kanade.tachiyomi.extension.all.imhentai
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.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 kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
class IMHentai(override val lang: String, private val imhLang: String) : ParsedHttpSource() {
|
||||
|
||||
override val baseUrl: String = "https://imhentai.xxx"
|
||||
override val name: String = "IMHentai"
|
||||
class IMHentai(
|
||||
lang: String = "all",
|
||||
override val mangaLang: String = LANGUAGE_MULTI,
|
||||
) : GalleryAdults(
|
||||
"IMHentai",
|
||||
"https://imhentai.xxx",
|
||||
lang = lang,
|
||||
) {
|
||||
override val supportsLatest = true
|
||||
override val useIntermediateSearch: Boolean = true
|
||||
override val supportAdvancedSearch: Boolean = true
|
||||
override val supportSpeechless: Boolean = true
|
||||
|
||||
override fun Element.mangaLang() =
|
||||
select("a:has(.thumb_flag)").attr("href")
|
||||
.removeSuffix("/").substringAfterLast("/")
|
||||
.let {
|
||||
// Include Speechless in search results
|
||||
if (it == LANGUAGE_SPEECHLESS) mangaLang else it
|
||||
}
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
.newBuilder()
|
||||
|
@ -57,271 +61,103 @@ class IMHentai(override val lang: String, private val imhLang: String) : ParsedH
|
|||
},
|
||||
).build()
|
||||
|
||||
// Popular
|
||||
override val favoritePath = "user/fav_pags.php"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
thumbnail_url = element.selectFirst(".inner_thumb img")?.let {
|
||||
it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src")
|
||||
/* Details */
|
||||
override fun Element.getInfo(tag: String): String {
|
||||
return select("li:has(.tags_text:contains($tag:)) .tag").map {
|
||||
it?.run {
|
||||
listOf(
|
||||
ownText().cleanTag(),
|
||||
select(".split_tag").text()
|
||||
.trim()
|
||||
.removePrefix("| ")
|
||||
.cleanTag(),
|
||||
)
|
||||
.filter { s -> s.isNotBlank() }
|
||||
.joinToString()
|
||||
}
|
||||
with(element.select(".caption a")) {
|
||||
url = this.attr("href")
|
||||
title = this.text()
|
||||
}
|
||||
}
|
||||
}.joinToString()
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector(): String = ".pagination li a:contains(Next):not([tabindex])"
|
||||
|
||||
override fun popularMangaSelector(): String = ".thumbs_container .thumb"
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_POPULAR))
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_LATEST))
|
||||
|
||||
override fun latestUpdatesSelector(): String = popularMangaSelector()
|
||||
|
||||
// Search
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith("id:")) {
|
||||
val id = query.substringAfter("id:")
|
||||
return client.newCall(GET("$baseUrl/gallery/$id/"))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val manga = mangaDetailsParse(response)
|
||||
manga.url = "/gallery/$id/"
|
||||
MangasPage(listOf(manga), false)
|
||||
}
|
||||
}
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector()
|
||||
|
||||
private fun toBinary(boolean: Boolean) = if (boolean) "1" else "0"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (filters.any { it is LanguageFilters && it.state.any { it.name == LANGUAGE_SPEECHLESS && it.state } }) { // edge case for language = speechless
|
||||
val url = "$baseUrl/language/speechless/".toHttpUrl().newBuilder()
|
||||
|
||||
if ((if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<SortOrderFilter>()[0].state == 0) {
|
||||
url.addPathSegment("popular")
|
||||
}
|
||||
return GET(url.build())
|
||||
} else {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("key", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
.addQueryParameter(getLanguageURIByName(imhLang).uri, toBinary(true)) // main language always enabled
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is LanguageFilters -> {
|
||||
filter.state.forEach {
|
||||
url.addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
}
|
||||
is CategoryFilters -> {
|
||||
filter.state.forEach {
|
||||
url.addQueryParameter(it.uri, toBinary(it.state))
|
||||
}
|
||||
}
|
||||
is SortOrderFilter -> {
|
||||
getSortOrderURIs().forEachIndexed { index, pair ->
|
||||
url.addQueryParameter(pair.second, toBinary(filter.state == index))
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.build())
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector(): String = popularMangaSelector()
|
||||
|
||||
// Details
|
||||
|
||||
private fun Elements.csvText(splitTagSeparator: String = ", "): String {
|
||||
return this.joinToString {
|
||||
listOf(
|
||||
it.ownText(),
|
||||
it.select(".split_tag").text()
|
||||
.trim()
|
||||
.removePrefix("| "),
|
||||
override fun Element.getDescription(): String {
|
||||
return (
|
||||
listOf("Parodies", "Characters", "Languages", "Category")
|
||||
.mapNotNull { tag ->
|
||||
getInfo(tag)
|
||||
.let { if (it.isNotBlank()) "$tag: $it" else null }
|
||||
} +
|
||||
listOfNotNull(
|
||||
selectFirst(".pages")?.ownText(),
|
||||
selectFirst(".subtitle")?.ownText()
|
||||
.let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null },
|
||||
)
|
||||
)
|
||||
.filter { s -> !s.isNullOrBlank() }
|
||||
.joinToString(splitTagSeparator)
|
||||
.joinToString("\n\n")
|
||||
.plus(
|
||||
if (preferences.shortTitle) {
|
||||
"\nFull title: ${mangaFullTitle("h1")}"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun Element.getCover() =
|
||||
selectFirst(".left_cover img")?.imgAttr()
|
||||
|
||||
override val mangaDetailInfoSelector = ".gallery_first"
|
||||
|
||||
/* Pages */
|
||||
override val pageUri = "view"
|
||||
override val pageSelector = ".gthumb"
|
||||
private val serverSelector = "load_server"
|
||||
|
||||
private fun serverNumber(document: Document, galleryId: String): String {
|
||||
return document.inputIdValueOf(serverSelector).takeIf {
|
||||
it.isNotBlank()
|
||||
} ?: when (galleryId.toInt()) {
|
||||
in 1..274825 -> "1"
|
||||
in 274826..403818 -> "2"
|
||||
in 403819..527143 -> "3"
|
||||
in 527144..632481 -> "4"
|
||||
in 632482..816010 -> "5"
|
||||
in 816011..970098 -> "6"
|
||||
in 970099..1121113 -> "7"
|
||||
else -> "8"
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
|
||||
title = document.selectFirst("div.right_details > h1")!!.text()
|
||||
|
||||
thumbnail_url = document.selectFirst("div.left_cover img")?.let {
|
||||
it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src")
|
||||
}
|
||||
|
||||
val mangaInfoElement = document.select(".galleries_info")
|
||||
val infoMap = mangaInfoElement.select("li:not(.pages)").associate {
|
||||
it.select("span.tags_text").text().removeSuffix(":") to it.select(".tag")
|
||||
}
|
||||
|
||||
artist = infoMap["Artists"]?.csvText(" | ")
|
||||
|
||||
author = artist
|
||||
|
||||
genre = infoMap["Tags"]?.csvText()
|
||||
|
||||
status = SManga.COMPLETED
|
||||
|
||||
val pages = mangaInfoElement.select("li.pages").text().substringAfter("Pages: ")
|
||||
val altTitle = document.select(".subtitle").text().ifBlank { null }
|
||||
|
||||
description = listOf(
|
||||
"Parodies",
|
||||
"Characters",
|
||||
"Groups",
|
||||
"Languages",
|
||||
"Category",
|
||||
).map { it to infoMap[it]?.csvText() }
|
||||
.let { listOf(Pair("Alternate Title", altTitle)) + it + listOf(Pair("Pages", pages)) }
|
||||
.filter { !it.second.isNullOrEmpty() }
|
||||
.joinToString("\n\n") { "${it.first}:\n${it.second}" }
|
||||
override fun getServer(document: Document, galleryId: String): String {
|
||||
val domain = baseUrl.toHttpUrl().host
|
||||
return "m${serverNumber(document, galleryId)}.$domain"
|
||||
}
|
||||
|
||||
// Chapters
|
||||
override fun pageRequestForm(document: Document, totalPages: String): FormBody {
|
||||
val galleryId = document.inputIdValueOf(galleryIdSelector)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(response.request.url.toString().replace("gallery", "view") + "1")
|
||||
name = "Chapter"
|
||||
chapter_number = 1f
|
||||
},
|
||||
)
|
||||
return FormBody.Builder()
|
||||
.add("server", serverNumber(document, galleryId))
|
||||
.add("u_id", document.inputIdValueOf(galleryIdSelector))
|
||||
.add("g_id", document.inputIdValueOf(loadIdSelector))
|
||||
.add("img_dir", document.inputIdValueOf(loadDirSelector))
|
||||
.add("visible_pages", "10")
|
||||
.add("total_pages", totalPages)
|
||||
.add("type", "2") // 1 would be "more", 2 is "all remaining"
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterListSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
// Pages
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val imageDir = document.select("#image_dir").`val`()
|
||||
val galleryId = document.select("#gallery_id").`val`()
|
||||
val uId = document.select("#u_id").`val`().toInt()
|
||||
|
||||
val randomServer = when (uId) {
|
||||
in 1..274825 -> "m1.imhentai.xxx"
|
||||
in 274826..403818 -> "m2.imhentai.xxx"
|
||||
in 403819..527143 -> "m3.imhentai.xxx"
|
||||
in 527144..632481 -> "m4.imhentai.xxx"
|
||||
in 632482..816010 -> "m5.imhentai.xxx"
|
||||
in 816011..970098 -> "m6.imhentai.xxx"
|
||||
in 970099..1121113 -> "m7.imhentai.xxx"
|
||||
else -> "m8.imhentai.xxx"
|
||||
}
|
||||
|
||||
val images = json.parseToJsonElement(
|
||||
document.selectFirst("script:containsData(var g_th)")!!.data()
|
||||
.substringAfter("$.parseJSON('").substringBefore("');").trim(),
|
||||
).jsonObject
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
for (image in images) {
|
||||
val iext = image.value.toString().replace("\"", "").split(",")[0]
|
||||
val iextPr = when (iext) {
|
||||
"p" -> "png"
|
||||
"b" -> "bmp"
|
||||
"g" -> "gif"
|
||||
else -> "jpg"
|
||||
/* Filters */
|
||||
override fun tagsParser(document: Document): List<Pair<String, String>> {
|
||||
return document.select(".stags .tag_btn")
|
||||
.mapNotNull {
|
||||
Pair(
|
||||
it.selectFirst(".list_tag")?.ownText() ?: "",
|
||||
it.select("a").attr("href")
|
||||
.removeSuffix("/").substringAfterLast('/'),
|
||||
)
|
||||
}
|
||||
pages.add(Page(image.key.toInt() - 1, "", "https://$randomServer/$imageDir/$galleryId/${image.key}.$iextPr"))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
|
||||
// Filters
|
||||
|
||||
private class SortOrderFilter(sortOrderURIs: List<Pair<String, String>>, state: Int) :
|
||||
Filter.Select<String>("Sort By", sortOrderURIs.map { it.first }.toTypedArray(), state)
|
||||
|
||||
private open class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state)
|
||||
private class LanguageFilter(name: String, uri: String = name) : SearchFlagFilter(name, uri, false)
|
||||
private class LanguageFilters(flags: List<LanguageFilter>) : Filter.Group<LanguageFilter>("Other Languages", flags)
|
||||
private class CategoryFilters(flags: List<SearchFlagFilter>) : Filter.Group<SearchFlagFilter>("Categories", flags)
|
||||
|
||||
override fun getFilterList() = getFilterList(SORT_ORDER_DEFAULT)
|
||||
|
||||
private fun getFilterList(sortOrderState: Int) = FilterList(
|
||||
SortOrderFilter(getSortOrderURIs(), sortOrderState),
|
||||
CategoryFilters(getCategoryURIs()),
|
||||
LanguageFilters(getLanguageURIs().filter { it.name != imhLang }), // exclude main lang
|
||||
Filter.Header("Speechless language: ignores all filters except \"Popular\" and \"Latest\" in Sorting Filter"),
|
||||
)
|
||||
|
||||
private fun getCategoryURIs() = listOf(
|
||||
SearchFlagFilter("Manga", "manga"),
|
||||
SearchFlagFilter("Doujinshi", "doujinshi"),
|
||||
SearchFlagFilter("Western", "western"),
|
||||
SearchFlagFilter("Image Set", "imageset"),
|
||||
SearchFlagFilter("Artist CG", "artistcg"),
|
||||
SearchFlagFilter("Game CG", "gamecg"),
|
||||
)
|
||||
|
||||
// update sort order indices in companion object if order is changed
|
||||
private fun getSortOrderURIs() = listOf(
|
||||
Pair("Popular", "pp"),
|
||||
Pair("Latest", "lt"),
|
||||
Pair("Downloads", "dl"),
|
||||
Pair("Top Rated", "tr"),
|
||||
)
|
||||
|
||||
private fun getLanguageURIs() = listOf(
|
||||
LanguageFilter(LANGUAGE_ENGLISH, "en"),
|
||||
LanguageFilter(LANGUAGE_JAPANESE, "jp"),
|
||||
LanguageFilter(LANGUAGE_SPANISH, "es"),
|
||||
LanguageFilter(LANGUAGE_FRENCH, "fr"),
|
||||
LanguageFilter(LANGUAGE_KOREAN, "kr"),
|
||||
LanguageFilter(LANGUAGE_GERMAN, "de"),
|
||||
LanguageFilter(LANGUAGE_RUSSIAN, "ru"),
|
||||
LanguageFilter(LANGUAGE_SPEECHLESS, ""),
|
||||
)
|
||||
|
||||
private fun getLanguageURIByName(name: String): LanguageFilter {
|
||||
return getLanguageURIs().first { it.name == name }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
// references to sort order indices
|
||||
private const val SORT_ORDER_POPULAR = 0
|
||||
private const val SORT_ORDER_LATEST = 1
|
||||
private const val SORT_ORDER_DEFAULT = SORT_ORDER_POPULAR
|
||||
|
||||
// references to be used in factory
|
||||
const val LANGUAGE_ENGLISH = "English"
|
||||
const val LANGUAGE_JAPANESE = "Japanese"
|
||||
const val LANGUAGE_SPANISH = "Spanish"
|
||||
const val LANGUAGE_FRENCH = "French"
|
||||
const val LANGUAGE_KOREAN = "Korean"
|
||||
const val LANGUAGE_GERMAN = "German"
|
||||
const val LANGUAGE_RUSSIAN = "Russian"
|
||||
const val LANGUAGE_SPEECHLESS = "Speechless"
|
||||
}
|
||||
override val idPrefixUri = "gallery"
|
||||
}
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
package eu.kanade.tachiyomi.extension.all.imhentai
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class IMHentaiFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
IMHentai("en", IMHentai.LANGUAGE_ENGLISH),
|
||||
IMHentai("ja", IMHentai.LANGUAGE_JAPANESE),
|
||||
IMHentai("es", IMHentai.LANGUAGE_SPANISH),
|
||||
IMHentai("fr", IMHentai.LANGUAGE_FRENCH),
|
||||
IMHentai("ko", IMHentai.LANGUAGE_KOREAN),
|
||||
IMHentai("de", IMHentai.LANGUAGE_GERMAN),
|
||||
IMHentai("ru", IMHentai.LANGUAGE_RUSSIAN),
|
||||
IMHentai("en", GalleryAdults.LANGUAGE_ENGLISH),
|
||||
IMHentai("ja", GalleryAdults.LANGUAGE_JAPANESE),
|
||||
IMHentai("es", GalleryAdults.LANGUAGE_SPANISH),
|
||||
IMHentai("fr", GalleryAdults.LANGUAGE_FRENCH),
|
||||
IMHentai("ko", GalleryAdults.LANGUAGE_KOREAN),
|
||||
IMHentai("de", GalleryAdults.LANGUAGE_GERMAN),
|
||||
IMHentai("ru", GalleryAdults.LANGUAGE_RUSSIAN),
|
||||
IMHentai("all", GalleryAdults.LANGUAGE_MULTI),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
ext {
|
||||
extName = 'HentaiFox'
|
||||
extClass = '.HentaiFox'
|
||||
extVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
|
@ -1,223 +0,0 @@
|
|||
package eu.kanade.tachiyomi.extension.en.hentaifox
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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 okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
class HentaiFox : ParsedHttpSource() {
|
||||
|
||||
override val name = "HentaiFox"
|
||||
|
||||
override val baseUrl = "https://hentaifox.com"
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return if (page == 2) {
|
||||
GET("$baseUrl/page/$page/", headers)
|
||||
} else {
|
||||
GET("$baseUrl/pag/$page/", headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "div.thumb"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
element.select("h2 a").let {
|
||||
title = it.text()
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
}
|
||||
thumbnail_url = element.selectFirst("img")!!.imgAttr()
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "li.page-item:last-of-type:not(.disabled)"
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return if (query.isNotEmpty()) {
|
||||
GET("$baseUrl/search/?q=$query&page=$page", headers)
|
||||
} else {
|
||||
var url = "$baseUrl/tag/"
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreFilter -> {
|
||||
url += "${filter.toUriPart()}/pag/$page/"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
GET(url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
return document.select("div.gallery_top").let { info ->
|
||||
SManga.create().apply {
|
||||
title = info.select("h1").text()
|
||||
genre = info.select("ul.tags a").joinToString { it.ownText() }
|
||||
artist = info.select("ul.artists a").joinToString { it.ownText() }
|
||||
thumbnail_url = info.select("img").first()!!.imgAttr()
|
||||
description = info.select("ul.parodies a")
|
||||
.let { e -> if (e.isNotEmpty()) "Parodies: ${e.joinToString { it.ownText() }}\n\n" else "" }
|
||||
description += info.select("ul.characters a")
|
||||
.let { e -> if (e.isNotEmpty()) "Characters: ${e.joinToString { it.ownText() }}\n\n" else "" }
|
||||
description += info.select("ul.groups a")
|
||||
.let { e -> if (e.isNotEmpty()) "Groups: ${e.joinToString { it.ownText() }}\n\n" else "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
name = "Chapter"
|
||||
// page path with a marker at the end
|
||||
url = "${response.request.url.toString().replace("/gallery/", "/g/")}#"
|
||||
// number of pages
|
||||
url += response.asJsoup().select("[id=load_pages]").attr("value")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
// Pages
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
// split the "url" to get the page path and number of pages
|
||||
return chapter.url.split("#").let { list ->
|
||||
Observable.just(listOf(1..list[1].toInt()).flatten().map { Page(it, list[0] + "$it/") })
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
return document.selectFirst("img#gimg")!!.imgAttr()
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
GenreFilter(),
|
||||
)
|
||||
|
||||
// Top 50 tags
|
||||
private class GenreFilter : UriPartFilter(
|
||||
"Category",
|
||||
arrayOf(
|
||||
Pair("<select>", "---"),
|
||||
Pair("Big breasts", "big-breasts"),
|
||||
Pair("Sole female", "sole-female"),
|
||||
Pair("Sole male", "sole-male"),
|
||||
Pair("Anal", "anal"),
|
||||
Pair("Nakadashi", "nakadashi"),
|
||||
Pair("Group", "group"),
|
||||
Pair("Stockings", "stockings"),
|
||||
Pair("Blowjob", "blowjob"),
|
||||
Pair("Schoolgirl uniform", "schoolgirl-uniform"),
|
||||
Pair("Rape", "rape"),
|
||||
Pair("Lolicon", "lolicon"),
|
||||
Pair("Glasses", "glasses"),
|
||||
Pair("Defloration", "defloration"),
|
||||
Pair("Ahegao", "ahegao"),
|
||||
Pair("Incest", "incest"),
|
||||
Pair("Shotacon", "shotacon"),
|
||||
Pair("X-ray", "x-ray"),
|
||||
Pair("Bondage", "bondage"),
|
||||
Pair("Full color", "full-color"),
|
||||
Pair("Double penetration", "double-penetration"),
|
||||
Pair("Femdom", "femdom"),
|
||||
Pair("Milf", "milf"),
|
||||
Pair("Yaoi", "yaoi"),
|
||||
Pair("Multi-work series", "multi-work-series"),
|
||||
Pair("Schoolgirl", "schoolgirl"),
|
||||
Pair("Mind break", "mind-break"),
|
||||
Pair("Paizuri", "paizuri"),
|
||||
Pair("Mosaic censorship", "mosaic-censorship"),
|
||||
Pair("Impregnation", "impregnation"),
|
||||
Pair("Males only", "males-only"),
|
||||
Pair("Sex toys", "sex-toys"),
|
||||
Pair("Sister", "sister"),
|
||||
Pair("Dark skin", "dark-skin"),
|
||||
Pair("Ffm threesome", "ffm-threesome"),
|
||||
Pair("Hairy", "hairy"),
|
||||
Pair("Cheating", "cheating"),
|
||||
Pair("Sweating", "sweating"),
|
||||
Pair("Yuri", "yuri"),
|
||||
Pair("Netorare", "netorare"),
|
||||
Pair("Full censorship", "full-censorship"),
|
||||
Pair("Schoolboy uniform", "schoolboy-uniform"),
|
||||
Pair("Dilf", "dilf"),
|
||||
Pair("Big penis", "big-penis"),
|
||||
Pair("Futanari", "futanari"),
|
||||
Pair("Swimsuit", "swimsuit"),
|
||||
Pair("Collar", "collar"),
|
||||
Pair("Uncensored", "uncensored"),
|
||||
Pair("Big ass", "big-ass"),
|
||||
Pair("Story arc", "story-arc"),
|
||||
Pair("Teacher", "teacher"),
|
||||
),
|
||||
)
|
||||
|
||||
private open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
private fun Element.imgAttr() = when {
|
||||
hasAttr("data-cfsrc") -> absUrl("data-cfsrc")
|
||||
hasAttr("data-src") -> absUrl("data-src")
|
||||
hasAttr("data-lazy-src") -> absUrl("data-lazy-src")
|
||||
hasAttr("srcset") -> absUrl("srcset").substringBefore(" ")
|
||||
else -> absUrl("src")
|
||||
}
|
||||
}
|