Add HentaiHere source (#10323)
This commit is contained in:
parent
2dd71d66f6
commit
7817a5fdde
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="eu.kanade.tachiyomi.extension">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".en.hentaihere.HentaiHereUrlActivity"
|
||||||
|
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="hentaihere.com"
|
||||||
|
android:pathPattern="/m/S..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,13 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'HentaiHere'
|
||||||
|
pkgNameSuffix = 'en.hentaihere'
|
||||||
|
extClass = '.HentaiHere'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 57 KiB |
|
@ -0,0 +1,324 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.hentaihere
|
||||||
|
|
||||||
|
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.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class HentaiHere : ParsedHttpSource() {
|
||||||
|
|
||||||
|
override val name = "HentaiHere"
|
||||||
|
|
||||||
|
override val baseUrl = "https://hentaihere.com"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
// Popular
|
||||||
|
override fun popularMangaRequest(page: Int) =
|
||||||
|
searchMangaRequest(page, "", getFilterList())
|
||||||
|
|
||||||
|
override fun popularMangaSelector() =
|
||||||
|
searchMangaSelector()
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element): SManga =
|
||||||
|
searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() =
|
||||||
|
searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// Search
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(PREFIX_ID_SEARCH)) {
|
||||||
|
val id = query.removePrefix(PREFIX_ID_SEARCH)
|
||||||
|
client.newCall(searchMangaByIdRequest(id))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response -> searchMangaByIdParse(response, id) }
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/m/$id")
|
||||||
|
|
||||||
|
private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
|
||||||
|
val details = mangaDetailsParse(response)
|
||||||
|
details.url = "/m/$id"
|
||||||
|
return MangasPage(listOf(details), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||||
|
val sortFilter = filterList.find { it is SortFilter } as SortFilter
|
||||||
|
val alphabetFilter = filterList.find { it is AlphabetFilter } as AlphabetFilter
|
||||||
|
val statusFilter = filterList.find { it is StatusFilter } as StatusFilter
|
||||||
|
val categoryFilter = filterList.find { it is CategoryFilter } as CategoryFilter
|
||||||
|
|
||||||
|
val sortIndex = sortFilter.state
|
||||||
|
val sortItem = sortFilterList[sortIndex]
|
||||||
|
val sortMin = if (sortIndex >= 5) "newest" else sortItem.first
|
||||||
|
|
||||||
|
val alphabetIndex = alphabetFilter.state
|
||||||
|
val alphabetItem = alphabetFilterList[alphabetIndex]
|
||||||
|
val alphabet = if (alphabetIndex != 0) "/${alphabetItem.first}" else ""
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// query + sort_min ~ /search?s=ore&sort=most-popular
|
||||||
|
query.isNotBlank() -> {
|
||||||
|
GET("$baseUrl/search?s=$query&sort=$sortMin&page=$page")
|
||||||
|
}
|
||||||
|
// category + sort_min + alphabet (optional) ~ /search/t34/newest/a
|
||||||
|
categoryFilter.state != 0 -> {
|
||||||
|
val category = categoryFilterList[categoryFilter.state].first
|
||||||
|
GET("$baseUrl/search/$category/$sortMin$alphabet?page=$page")
|
||||||
|
}
|
||||||
|
// status + alphabet (optional) ~ /directory/ongoing/a
|
||||||
|
statusFilter.state != 0 -> {
|
||||||
|
val status = statusFilterList[statusFilter.state].first
|
||||||
|
GET("$baseUrl/directory/$status$alphabet?page=$page")
|
||||||
|
}
|
||||||
|
// sort + alphabet (optional) ~ /directory/staff-pick/a
|
||||||
|
else -> {
|
||||||
|
val sort = sortItem.first
|
||||||
|
GET("$baseUrl/directory/$sort$alphabet?page=$page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = ".item"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element): SManga {
|
||||||
|
val a = element.select(".pos-rlt a")
|
||||||
|
val img = element.select(".pos-rlt img")
|
||||||
|
val mutedText = element.select("div:not(.pos-rtl) > .text-muted").text()
|
||||||
|
val artistName = mutedText
|
||||||
|
.substringAfter("by ")
|
||||||
|
.substringBefore(".")
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
setUrlWithoutDomain(a.attr("href"))
|
||||||
|
title = img.attr("alt")
|
||||||
|
author = when (artistName) {
|
||||||
|
"-", "Unknown" -> null
|
||||||
|
else -> artistName
|
||||||
|
}
|
||||||
|
thumbnail_url = img.attr("src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() =
|
||||||
|
".pagination > li:last-child:not(.disabled)"
|
||||||
|
|
||||||
|
// Latest
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request =
|
||||||
|
GET("$baseUrl/directory/newest?page=$page")
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element): SManga =
|
||||||
|
searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() =
|
||||||
|
searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// Details
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
|
val genres = document.select("#info > div:nth-child(10) > a")
|
||||||
|
val licensed = genres.find { it.text() == "Licensed" }
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
title = document.select("*[itemprop='name']").text().trim()
|
||||||
|
author = document.select("#info > div:nth-child(9) > a").text()
|
||||||
|
description = document.select("#info > div:last-child").text()
|
||||||
|
.substringAfter("Brief Summary:")
|
||||||
|
.trim()
|
||||||
|
genre = genres.joinToString(", ") { it.text() }
|
||||||
|
status = if (licensed != null) {
|
||||||
|
SManga.LICENSED
|
||||||
|
} else {
|
||||||
|
document.select("#info > div:nth-child(4) > a").text().trim().toStatus()
|
||||||
|
}
|
||||||
|
thumbnail_url = document.select("#cover img").attr("src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters
|
||||||
|
override fun chapterListSelector() = "li.sub-chp > a"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element): SChapter {
|
||||||
|
val chapterName = element
|
||||||
|
.text()
|
||||||
|
.substringBefore("(")
|
||||||
|
.trim()
|
||||||
|
val chapterNumber = chapterName
|
||||||
|
.substringBefore(" ")
|
||||||
|
.toFloatOrNull() ?: -1f
|
||||||
|
|
||||||
|
return SChapter.create().apply {
|
||||||
|
setUrlWithoutDomain(element.attr("href"))
|
||||||
|
name = chapterName
|
||||||
|
chapter_number = chapterNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
override fun pageListParse(response: Response): List<Page> =
|
||||||
|
json.decodeFromString<List<String>>(
|
||||||
|
response.body!!.string()
|
||||||
|
.substringAfter("var rff_imageList = ")
|
||||||
|
.substringBefore(";")
|
||||||
|
).mapIndexed { i, imagePath ->
|
||||||
|
Page(i, "", "$IMAGE_SERVER_URL/hentai$imagePath")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document) =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
override fun getFilterList(): FilterList {
|
||||||
|
return FilterList(
|
||||||
|
Filter.Header("Note: Some ignored for text search, category!"),
|
||||||
|
Filter.Header("Note: Ignored when used with status!"),
|
||||||
|
SortFilter(sortFilterList.map { it.second }.toTypedArray()),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Note: Ignored for text search!"),
|
||||||
|
AlphabetFilter(alphabetFilterList.map { it.second }.toTypedArray()),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Note: Ignored for text search, category!"),
|
||||||
|
StatusFilter(statusFilterList.map { it.second }.toTypedArray()),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Note: Ignored for text search!"),
|
||||||
|
CategoryFilter(categoryFilterList.map { it.second }.toTypedArray()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortFilterList = listOf(
|
||||||
|
Pair("newest", "Newest"),
|
||||||
|
Pair("most-popular", "Most Popular"),
|
||||||
|
Pair("last-updated", "Last Updated"),
|
||||||
|
Pair("most-viewed", "Most Viewed"),
|
||||||
|
Pair("alphabetical", "Alphabetical"),
|
||||||
|
Pair("", "----"),
|
||||||
|
Pair("staff-pick", "Staff Pick"),
|
||||||
|
Pair("last-month", "Popular (Monthly)"),
|
||||||
|
Pair("last-week", "Popular (Weekly)"),
|
||||||
|
Pair("yesterday", "Popular (Daily)"),
|
||||||
|
Pair("trending", "Trending"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val alphabetFilterList = listOf(
|
||||||
|
Pair("", "All"),
|
||||||
|
Pair("a", "A"),
|
||||||
|
Pair("b", "B"),
|
||||||
|
Pair("c", "C"),
|
||||||
|
Pair("d", "D"),
|
||||||
|
Pair("e", "E"),
|
||||||
|
Pair("f", "F"),
|
||||||
|
Pair("g", "G"),
|
||||||
|
Pair("h", "H"),
|
||||||
|
Pair("i", "I"),
|
||||||
|
Pair("j", "J"),
|
||||||
|
Pair("k", "K"),
|
||||||
|
Pair("l", "L"),
|
||||||
|
Pair("m", "M"),
|
||||||
|
Pair("n", "N"),
|
||||||
|
Pair("o", "O"),
|
||||||
|
Pair("p", "P"),
|
||||||
|
Pair("q", "Q"),
|
||||||
|
Pair("r", "R"),
|
||||||
|
Pair("s", "S"),
|
||||||
|
Pair("t", "T"),
|
||||||
|
Pair("u", "U"),
|
||||||
|
Pair("v", "V"),
|
||||||
|
Pair("w", "W"),
|
||||||
|
Pair("x", "X"),
|
||||||
|
Pair("y", "Y"),
|
||||||
|
Pair("z", "Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val statusFilterList = listOf(
|
||||||
|
Pair("", "All"),
|
||||||
|
Pair("ongoing", "Ongoing"),
|
||||||
|
Pair("completed", "Completed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// /tags/category
|
||||||
|
// copy($$('.item > a').map(el => `Pair("t${/[^T]+$/.exec(el.href)[0]}", "${el.querySelector("span.clear > span").textContent}"),`).join("\r\n"))
|
||||||
|
val categoryFilterList = listOf(
|
||||||
|
Pair("", "All"),
|
||||||
|
Pair("t34", "Adult"),
|
||||||
|
Pair("t7", "Anal"),
|
||||||
|
Pair("t372", "Beastiality"),
|
||||||
|
Pair("t20", "Big Breasts"),
|
||||||
|
Pair("t43", "Comedy"),
|
||||||
|
Pair("t46", "Compilation"),
|
||||||
|
Pair("t42", "Doujinshi"),
|
||||||
|
Pair("t40", "Ecchi"),
|
||||||
|
Pair("t6", "Fantasy"),
|
||||||
|
Pair("t14", "Futanari"),
|
||||||
|
Pair("t302", "Guro"),
|
||||||
|
Pair("t31", "Harem"),
|
||||||
|
Pair("t15", "Incest"),
|
||||||
|
Pair("t2650", "Isekai (Otherworld)"),
|
||||||
|
Pair("t2158", "Korean Comic"),
|
||||||
|
Pair("t50", "Licensed"),
|
||||||
|
Pair("t17", "Lolicon"),
|
||||||
|
Pair("t30", "Mecha"),
|
||||||
|
Pair("t2503", "No Penetration"),
|
||||||
|
Pair("t33", "Oneshot"),
|
||||||
|
Pair("t23", "Rape"),
|
||||||
|
Pair("t567", "Reverse Harem"),
|
||||||
|
Pair("t41", "Romance"),
|
||||||
|
Pair("t432", "Scat"),
|
||||||
|
Pair("t48", "School Life"),
|
||||||
|
Pair("t5", "Sci-fi"),
|
||||||
|
Pair("t32", "Serialized"),
|
||||||
|
Pair("t44", "Shotacon"),
|
||||||
|
Pair("t49", "Tragedy"),
|
||||||
|
Pair("t47", "Uncensored"),
|
||||||
|
Pair("t27", "Yaoi"),
|
||||||
|
Pair("t28", "Yuri"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class SortFilter(sortables: Array<String>, state: Int = 1) :
|
||||||
|
Filter.Select<String>("Sort", sortables, state)
|
||||||
|
|
||||||
|
class AlphabetFilter(alphabet: Array<String>) :
|
||||||
|
Filter.Select<String>("Starts With", alphabet, 0)
|
||||||
|
|
||||||
|
class StatusFilter(statuses: Array<String>) :
|
||||||
|
Filter.Select<String>("Status", statuses, 0)
|
||||||
|
|
||||||
|
class CategoryFilter(categories: Array<String>) :
|
||||||
|
Filter.Select<String>("Category", categories, 0)
|
||||||
|
|
||||||
|
private fun String.toStatus(): Int = when (this) {
|
||||||
|
"Completed" -> SManga.COMPLETED
|
||||||
|
"Ongoing" -> SManga.ONGOING
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val IMAGE_SERVER_URL = "https://hentaicdn.com"
|
||||||
|
const val PREFIX_ID_SEARCH = "id:"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.hentaihere
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Springboard that accepts https://hentaihere.com/m/Sxxxxx intents and redirects them to
|
||||||
|
* the main Tachiyomi process.
|
||||||
|
*/
|
||||||
|
class HentaiHereUrlActivity : 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", "${HentaiHere.PREFIX_ID_SEARCH}$id")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("HentaiHereUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("HentaiHereUrlActivity", "could not parse uri from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue