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
This commit is contained in:
Cuong M. Tran 2024-04-28 13:51:04 +07:00 committed by Draff
parent 1077a96940
commit 935bd089fc
27 changed files with 1364 additions and 772 deletions

View File

@ -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>

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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"
}
}

View File

@ -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")

View File

@ -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()

View File

@ -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"

View File

@ -1,7 +1,9 @@
ext {
extName = 'AsmHentai'
extClass = '.ASMHFactory'
extVersionCode = 1
themePkg = 'galleryadults'
baseUrl = 'https://asmhentai.com'
overrideVersionCode = 2
isNsfw = true
}

View File

@ -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),
)
}

View File

@ -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 popularMangaNextPageSelector(): String = "li.active + li:not(.disabled)"
// Latest
override fun latestUpdatesNextPageSelector(): String? {
throw UnsupportedOperationException()
}
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("/"),
)
}
.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")
override fun Element.getDescription(): String {
return (
listOf("Parodies", "Characters", "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
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 chapterListSelector(): String {
throw UnsupportedOperationException()
}
/* Search */
override val favoritePath = "inc/user.php?act=favs"
override fun chapterFromElement(element: Element): SChapter {
throw UnsupportedOperationException()
}
override val mangaDetailInfoSelector = ".book_page"
// Pages
override val galleryIdSelector = "load_id"
override val totalPagesSelector = "t_pages"
override val pageUri = "gallery"
override val pageSelector = ".preview_thumb"
// 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()) {
override fun pageRequestForm(document: Document, totalPages: String): FormBody {
val token = document.select("[name=csrf-token]").attr("content")
val form = FormBody.Builder()
return FormBody.Builder()
.add("_token", token)
.add("id", document.inputIdValueOf("load_id"))
.add("dir", document.inputIdValueOf("load_dir"))
.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()
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(),
/* 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('/'),
)
class TagFilter : Filter.Text("Tags")
companion object {
const val PREFIX_ID_SEARCH = "id:"
}
}
override fun getFilterList() = FilterList(
listOf(
Filter.Header("HINT: Separate search term with comma (,)"),
) + super.getFilterList().list,
)
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'HentaiFox'
extClass = '.HentaiFoxFactory'
themePkg = 'galleryadults'
baseUrl = 'https://hentaifox.com'
overrideVersionCode = 6
isNsfw = true
}
apply from: "$rootDir/common.gradle"

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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"
}

View File

@ -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),
)
}

View File

@ -1,7 +1,9 @@
ext {
extName = 'IMHentai'
extClass = '.IMHentaiFactory'
extVersionCode = 14
themePkg = 'galleryadults'
baseUrl = 'https://imhentai.xxx'
overrideVersionCode = 15
isNsfw = true
}

View File

@ -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")
}
with(element.select(".caption a")) {
url = this.attr("href")
title = this.text()
}
}
}
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 {
/* Details */
override fun Element.getInfo(tag: String): String {
return select("li:has(.tags_text:contains($tag:)) .tag").map {
it?.run {
listOf(
it.ownText(),
it.select(".split_tag").text()
ownText().cleanTag(),
select(".split_tag").text()
.trim()
.removePrefix("| "),
.removePrefix("| ")
.cleanTag(),
)
.filter { s -> !s.isNullOrBlank() }
.joinToString(splitTagSeparator)
.filter { s -> s.isNotBlank() }
.joinToString()
}
}.joinToString()
}
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}" }
}
// Chapters
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
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 },
)
)
.joinToString("\n\n")
.plus(
if (preferences.shortTitle) {
"\nFull title: ${mangaFullTitle("h1")}"
} else {
""
},
)
}
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
override fun Element.getCover() =
selectFirst(".left_cover img")?.imgAttr()
override fun chapterListSelector(): String = throw UnsupportedOperationException()
override val mangaDetailInfoSelector = ".gallery_first"
// Pages
/* Pages */
override val pageUri = "view"
override val pageSelector = ".gthumb"
private val serverSelector = "load_server"
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"
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"
}
}
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"
}
pages.add(Page(image.key.toInt() - 1, "", "https://$randomServer/$imageDir/$galleryId/${image.key}.$iextPr"))
}
return pages
override fun getServer(document: Document, galleryId: String): String {
val domain = baseUrl.toHttpUrl().host
return "m${serverNumber(document, galleryId)}.$domain"
}
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
override fun pageRequestForm(document: Document, totalPages: String): FormBody {
val galleryId = document.inputIdValueOf(galleryIdSelector)
// Filters
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()
}
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"),
/* 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('/'),
)
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"
}

View File

@ -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),
)
}

View File

@ -1,8 +0,0 @@
ext {
extName = 'HentaiFox'
extClass = '.HentaiFox'
extVersionCode = 5
isNsfw = true
}
apply from: "$rootDir/common.gradle"

View File

@ -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")
}
}