|
|
|
|
@ -20,43 +20,44 @@ import kotlin.math.min
|
|
|
|
|
@OptIn(ExperimentalUnsignedTypes::class)
|
|
|
|
|
@MangaSourceParser("HITOMILA", "Hitomi.La", type = ContentType.HENTAI)
|
|
|
|
|
class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.HITOMILA) {
|
|
|
|
|
|
|
|
|
|
override val configKeyDomain = ConfigKey.Domain("hitomi.la")
|
|
|
|
|
|
|
|
|
|
private val ltnBaseUrl get() = "https://${getDomain("ltn")}"
|
|
|
|
|
|
|
|
|
|
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
|
|
|
|
|
SortOrder.NEWEST,
|
|
|
|
|
SortOrder.POPULARITY,
|
|
|
|
|
)
|
|
|
|
|
override val availableSortOrders: Set<SortOrder> =
|
|
|
|
|
EnumSet.of(
|
|
|
|
|
SortOrder.NEWEST,
|
|
|
|
|
SortOrder.POPULARITY,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private val localeMap: Map<Locale, String> = mapOf(
|
|
|
|
|
Locale("id") to "indonesian",
|
|
|
|
|
Locale("jv") to "javanese",
|
|
|
|
|
Locale("ca") to "catalan",
|
|
|
|
|
Locale("ceb") to "cebuano",
|
|
|
|
|
Locale("cs") to "czech",
|
|
|
|
|
Locale("da") to "danish",
|
|
|
|
|
Locale("de") to "german",
|
|
|
|
|
Locale("et") to "estonian",
|
|
|
|
|
Locale.ENGLISH to "english",
|
|
|
|
|
Locale("es") to "spanish",
|
|
|
|
|
Locale("eo") to "esperanto",
|
|
|
|
|
Locale("fr") to "french",
|
|
|
|
|
Locale("it") to "italian",
|
|
|
|
|
Locale("hi") to "hindi",
|
|
|
|
|
Locale("hu") to "hungarian",
|
|
|
|
|
Locale("pl") to "polish",
|
|
|
|
|
Locale("pt") to "portuguese",
|
|
|
|
|
Locale("vi") to "vietnamese",
|
|
|
|
|
Locale("tr") to "turkish",
|
|
|
|
|
Locale("ru") to "russian",
|
|
|
|
|
Locale("uk") to "ukrainian",
|
|
|
|
|
Locale("ar") to "arabic",
|
|
|
|
|
Locale.KOREAN to "korean",
|
|
|
|
|
Locale.CHINESE to "chinese",
|
|
|
|
|
Locale.JAPANESE to "japanese",
|
|
|
|
|
)
|
|
|
|
|
private val localeMap: Map<Locale, String> =
|
|
|
|
|
mapOf(
|
|
|
|
|
Locale("id") to "indonesian",
|
|
|
|
|
Locale("jv") to "javanese",
|
|
|
|
|
Locale("ca") to "catalan",
|
|
|
|
|
Locale("ceb") to "cebuano",
|
|
|
|
|
Locale("cs") to "czech",
|
|
|
|
|
Locale("da") to "danish",
|
|
|
|
|
Locale("de") to "german",
|
|
|
|
|
Locale("et") to "estonian",
|
|
|
|
|
Locale.ENGLISH to "english",
|
|
|
|
|
Locale("es") to "spanish",
|
|
|
|
|
Locale("eo") to "esperanto",
|
|
|
|
|
Locale("fr") to "french",
|
|
|
|
|
Locale("it") to "italian",
|
|
|
|
|
Locale("hi") to "hindi",
|
|
|
|
|
Locale("hu") to "hungarian",
|
|
|
|
|
Locale("pl") to "polish",
|
|
|
|
|
Locale("pt") to "portuguese",
|
|
|
|
|
Locale("vi") to "vietnamese",
|
|
|
|
|
Locale("tr") to "turkish",
|
|
|
|
|
Locale("ru") to "russian",
|
|
|
|
|
Locale("uk") to "ukrainian",
|
|
|
|
|
Locale("ar") to "arabic",
|
|
|
|
|
Locale.KOREAN to "korean",
|
|
|
|
|
Locale.CHINESE to "chinese",
|
|
|
|
|
Locale.JAPANESE to "japanese",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private fun Locale?.getSiteLang(): String {
|
|
|
|
|
return when (this) {
|
|
|
|
|
@ -76,14 +77,16 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
val doc = webClient.httpGet("https://$domain/alltags-$alphabet.html").parseHtml()
|
|
|
|
|
|
|
|
|
|
doc.select(".posts > li").mapNotNull { element ->
|
|
|
|
|
val num = element.ownText().let {
|
|
|
|
|
Regex("""\((\d+)\)""").find(it)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
|
|
|
|
}
|
|
|
|
|
val num =
|
|
|
|
|
element.ownText().let {
|
|
|
|
|
Regex("""\((\d+)\)""").find(it)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (num > 100) {
|
|
|
|
|
val url = element.selectFirst("a")
|
|
|
|
|
val href = url?.attrAsRelativeUrl("href")
|
|
|
|
|
?: return@mapNotNull null
|
|
|
|
|
val href =
|
|
|
|
|
url?.attrAsRelativeUrl("href")
|
|
|
|
|
?: return@mapNotNull null
|
|
|
|
|
|
|
|
|
|
MangaTag(
|
|
|
|
|
title = url.ownText().toCamelCase(),
|
|
|
|
|
@ -101,7 +104,10 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
|
|
|
|
|
private var cachedSearchIds: List<Int> = emptyList()
|
|
|
|
|
|
|
|
|
|
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
|
|
|
|
override suspend fun getList(
|
|
|
|
|
offset: Int,
|
|
|
|
|
filter: MangaListFilter?,
|
|
|
|
|
): List<Manga> {
|
|
|
|
|
return when (filter) {
|
|
|
|
|
is MangaListFilter.Advanced -> {
|
|
|
|
|
if (filter.tags.isEmpty()) {
|
|
|
|
|
@ -116,18 +122,19 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (offset == 0) {
|
|
|
|
|
val query = filter.tags.joinToString(" ") { it.key }.let {
|
|
|
|
|
val lang = filter.locale.getSiteLang()
|
|
|
|
|
if (lang != "all") {
|
|
|
|
|
"$it language:$lang"
|
|
|
|
|
} else {
|
|
|
|
|
it
|
|
|
|
|
val query =
|
|
|
|
|
filter.tags.joinToString(" ") { it.key }.let {
|
|
|
|
|
val lang = filter.locale.getSiteLang()
|
|
|
|
|
if (lang != "all") {
|
|
|
|
|
"$it language:$lang"
|
|
|
|
|
} else {
|
|
|
|
|
it
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedSearchIds = hitomiSearch(query,filter.sortOrder == SortOrder.POPULARITY).toList()
|
|
|
|
|
cachedSearchIds = hitomiSearch(query, filter.sortOrder == SortOrder.POPULARITY).toList()
|
|
|
|
|
}
|
|
|
|
|
cachedSearchIds.subList(offset, min(offset+25, cachedSearchIds.size))
|
|
|
|
|
cachedSearchIds.subList(offset, min(offset + 25, cachedSearchIds.size))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -135,7 +142,7 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
if (offset == 0) {
|
|
|
|
|
cachedSearchIds = hitomiSearch(filter.query, filter.sortOrder == SortOrder.POPULARITY).toList()
|
|
|
|
|
}
|
|
|
|
|
cachedSearchIds.subList(offset, min(offset+25, cachedSearchIds.size))
|
|
|
|
|
cachedSearchIds.subList(offset, min(offset + 25, cachedSearchIds.size))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
else -> getGalleryIDsFromNozomi(null, "popular", "all", offset.nextOffsetRange())
|
|
|
|
|
@ -143,85 +150,94 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun Int.nextOffsetRange(): LongRange {
|
|
|
|
|
val bytes = this*4L
|
|
|
|
|
return bytes.until(bytes+100L)
|
|
|
|
|
val bytes = this * 4L
|
|
|
|
|
return bytes.until(bytes + 100L)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun hitomiSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> = coroutineScope {
|
|
|
|
|
val terms = query
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(Regex("""^\?"""), "")
|
|
|
|
|
.lowercase()
|
|
|
|
|
.split(Regex("\\s+"))
|
|
|
|
|
.map {
|
|
|
|
|
it.replace('_', ' ')
|
|
|
|
|
private suspend fun hitomiSearch(
|
|
|
|
|
query: String,
|
|
|
|
|
sortByPopularity: Boolean = false,
|
|
|
|
|
): Set<Int> =
|
|
|
|
|
coroutineScope {
|
|
|
|
|
val terms =
|
|
|
|
|
query
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(Regex("""^\?"""), "")
|
|
|
|
|
.lowercase()
|
|
|
|
|
.split(Regex("\\s+"))
|
|
|
|
|
.map {
|
|
|
|
|
it.replace('_', ' ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val positiveTerms = LinkedList<String>()
|
|
|
|
|
val negativeTerms = LinkedList<String>()
|
|
|
|
|
|
|
|
|
|
for (term in terms) {
|
|
|
|
|
if (term.startsWith("-")) {
|
|
|
|
|
negativeTerms.push(term.removePrefix("-"))
|
|
|
|
|
} else if (term.isNotBlank()) {
|
|
|
|
|
positiveTerms.push(term)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val positiveTerms = LinkedList<String>()
|
|
|
|
|
val negativeTerms = LinkedList<String>()
|
|
|
|
|
val positiveResults =
|
|
|
|
|
positiveTerms.map {
|
|
|
|
|
async {
|
|
|
|
|
runCatching {
|
|
|
|
|
getGalleryIDsForQuery(it)
|
|
|
|
|
}.getOrDefault(emptySet())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (term in terms) {
|
|
|
|
|
if (term.startsWith("-"))
|
|
|
|
|
negativeTerms.push(term.removePrefix("-"))
|
|
|
|
|
else if (term.isNotBlank())
|
|
|
|
|
positiveTerms.push(term)
|
|
|
|
|
}
|
|
|
|
|
val negativeResults =
|
|
|
|
|
negativeTerms.map {
|
|
|
|
|
async {
|
|
|
|
|
runCatching {
|
|
|
|
|
getGalleryIDsForQuery(it)
|
|
|
|
|
}.getOrDefault(emptySet())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val positiveResults = positiveTerms.map {
|
|
|
|
|
async {
|
|
|
|
|
runCatching {
|
|
|
|
|
getGalleryIDsForQuery(it)
|
|
|
|
|
}.getOrDefault(emptySet())
|
|
|
|
|
val results =
|
|
|
|
|
when {
|
|
|
|
|
sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all")
|
|
|
|
|
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all")
|
|
|
|
|
else -> emptySet()
|
|
|
|
|
}.toMutableSet()
|
|
|
|
|
|
|
|
|
|
fun filterPositive(newResults: Set<Int>) {
|
|
|
|
|
when {
|
|
|
|
|
results.isEmpty() -> results.addAll(newResults)
|
|
|
|
|
else -> results.retainAll(newResults)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val negativeResults = negativeTerms.map {
|
|
|
|
|
async {
|
|
|
|
|
runCatching {
|
|
|
|
|
getGalleryIDsForQuery(it)
|
|
|
|
|
}.getOrDefault(emptySet())
|
|
|
|
|
fun filterNegative(newResults: Set<Int>) {
|
|
|
|
|
results.removeAll(newResults)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val results = when {
|
|
|
|
|
sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all")
|
|
|
|
|
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all")
|
|
|
|
|
else -> emptySet()
|
|
|
|
|
}.toMutableSet()
|
|
|
|
|
|
|
|
|
|
fun filterPositive(newResults: Set<Int>) {
|
|
|
|
|
when {
|
|
|
|
|
results.isEmpty() -> results.addAll(newResults)
|
|
|
|
|
else -> results.retainAll(newResults)
|
|
|
|
|
// positive results
|
|
|
|
|
positiveResults.forEach {
|
|
|
|
|
filterPositive(it.await())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun filterNegative(newResults: Set<Int>) {
|
|
|
|
|
results.removeAll(newResults)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//positive results
|
|
|
|
|
positiveResults.forEach {
|
|
|
|
|
filterPositive(it.await())
|
|
|
|
|
}
|
|
|
|
|
// negative results
|
|
|
|
|
negativeResults.forEach {
|
|
|
|
|
filterNegative(it.await())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//negative results
|
|
|
|
|
negativeResults.forEach {
|
|
|
|
|
filterNegative(it.await())
|
|
|
|
|
results
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//search.js
|
|
|
|
|
private suspend fun getGalleryIDsForQuery(query: String) : Set<Int> {
|
|
|
|
|
// search.js
|
|
|
|
|
private suspend fun getGalleryIDsForQuery(query: String): Set<Int> {
|
|
|
|
|
query.replace("_", " ").let {
|
|
|
|
|
if (it.indexOf(':') > -1) {
|
|
|
|
|
val sides = it.split(":")
|
|
|
|
|
val ns = sides[0]
|
|
|
|
|
var tag = sides[1]
|
|
|
|
|
|
|
|
|
|
var area : String? = ns
|
|
|
|
|
var area: String? = ns
|
|
|
|
|
var language = "all"
|
|
|
|
|
when (ns) {
|
|
|
|
|
"female", "male" -> {
|
|
|
|
|
@ -240,35 +256,39 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
|
|
|
|
|
val key = hashTerm(it)
|
|
|
|
|
val node = getGalleryNodeAtAddress(0)
|
|
|
|
|
val data = bSearch(key, node)
|
|
|
|
|
?: return emptySet()
|
|
|
|
|
val data =
|
|
|
|
|
bSearch(key, node)
|
|
|
|
|
?: return emptySet()
|
|
|
|
|
|
|
|
|
|
return getGalleryIDsFromData(data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun getGalleryIDsFromData(data: Pair<Long, Int>) : Set<Int> {
|
|
|
|
|
private suspend fun getGalleryIDsFromData(data: Pair<Long, Int>): Set<Int> {
|
|
|
|
|
val url = "$ltnBaseUrl/galleriesindex/galleries.${galleriesIndexVersion.get()}.data"
|
|
|
|
|
val (offset, length) = data
|
|
|
|
|
if (length > 100000000 || length <= 0)
|
|
|
|
|
if (length > 100000000 || length <= 0) {
|
|
|
|
|
throw Exception("length $length is too long")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val inbuf = getRangedResponse(url, offset.until(offset+length))
|
|
|
|
|
val inbuf = getRangedResponse(url, offset.until(offset + length))
|
|
|
|
|
|
|
|
|
|
val galleryIDs = mutableSetOf<Int>()
|
|
|
|
|
|
|
|
|
|
val buffer = ByteBuffer
|
|
|
|
|
.wrap(inbuf)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
val buffer =
|
|
|
|
|
ByteBuffer
|
|
|
|
|
.wrap(inbuf)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
|
|
|
|
|
val numberOfGalleryIDs = buffer.int
|
|
|
|
|
|
|
|
|
|
val expectedLength = numberOfGalleryIDs*4+4
|
|
|
|
|
val expectedLength = numberOfGalleryIDs * 4 + 4
|
|
|
|
|
|
|
|
|
|
if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0)
|
|
|
|
|
if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0) {
|
|
|
|
|
throw Exception("number_of_galleryids $numberOfGalleryIDs is too long")
|
|
|
|
|
else if (inbuf.size != expectedLength)
|
|
|
|
|
} else if (inbuf.size != expectedLength) {
|
|
|
|
|
throw Exception("inbuf.byteLength ${inbuf.size} != expected_length $expectedLength")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (i in 0.until(numberOfGalleryIDs))
|
|
|
|
|
galleryIDs.add(buffer.int)
|
|
|
|
|
@ -276,64 +296,85 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
return galleryIDs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun bSearch(key: UByteArray, node: Node) : Pair<Long, Int>? {
|
|
|
|
|
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int {
|
|
|
|
|
private suspend fun bSearch(
|
|
|
|
|
key: UByteArray,
|
|
|
|
|
node: Node,
|
|
|
|
|
): Pair<Long, Int>? {
|
|
|
|
|
fun compareArrayBuffers(
|
|
|
|
|
dv1: UByteArray,
|
|
|
|
|
dv2: UByteArray,
|
|
|
|
|
): Int {
|
|
|
|
|
val top = min(dv1.size, dv2.size)
|
|
|
|
|
|
|
|
|
|
for (i in 0.until(top)) {
|
|
|
|
|
if (dv1[i] < dv2[i])
|
|
|
|
|
if (dv1[i] < dv2[i]) {
|
|
|
|
|
return -1
|
|
|
|
|
else if (dv1[i] > dv2[i])
|
|
|
|
|
} else if (dv1[i] > dv2[i]) {
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun locateKey(key: UByteArray, node: Node) : Pair<Boolean, Int> {
|
|
|
|
|
fun locateKey(
|
|
|
|
|
key: UByteArray,
|
|
|
|
|
node: Node,
|
|
|
|
|
): Pair<Boolean, Int> {
|
|
|
|
|
for (i in node.keys.indices) {
|
|
|
|
|
val cmpResult = compareArrayBuffers(key, node.keys[i])
|
|
|
|
|
|
|
|
|
|
if (cmpResult <= 0)
|
|
|
|
|
return Pair(cmpResult==0, i)
|
|
|
|
|
if (cmpResult <= 0) {
|
|
|
|
|
return Pair(cmpResult == 0, i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Pair(false, node.keys.size)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun isLeaf(node: Node) : Boolean {
|
|
|
|
|
fun isLeaf(node: Node): Boolean {
|
|
|
|
|
for (subnode in node.subNodeAddresses)
|
|
|
|
|
if (subnode != 0L)
|
|
|
|
|
if (subnode != 0L) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node.keys.isEmpty())
|
|
|
|
|
if (node.keys.isEmpty()) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val (there, where) = locateKey(key, node)
|
|
|
|
|
if (there)
|
|
|
|
|
if (there) {
|
|
|
|
|
return node.datas[where]
|
|
|
|
|
else if (isLeaf(node))
|
|
|
|
|
} else if (isLeaf(node)) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val nextNode = getGalleryNodeAtAddress(node.subNodeAddresses[where])
|
|
|
|
|
return bSearch(key, nextNode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String, range: LongRange? = null) : Set<Int> {
|
|
|
|
|
val nozomiAddress = when(area) {
|
|
|
|
|
null -> "$ltnBaseUrl/$tag-$language.nozomi"
|
|
|
|
|
else -> "$ltnBaseUrl/$area/$tag-$language.nozomi"
|
|
|
|
|
}
|
|
|
|
|
private suspend fun getGalleryIDsFromNozomi(
|
|
|
|
|
area: String?,
|
|
|
|
|
tag: String,
|
|
|
|
|
language: String,
|
|
|
|
|
range: LongRange? = null,
|
|
|
|
|
): Set<Int> {
|
|
|
|
|
val nozomiAddress =
|
|
|
|
|
when (area) {
|
|
|
|
|
null -> "$ltnBaseUrl/$tag-$language.nozomi"
|
|
|
|
|
else -> "$ltnBaseUrl/$area/$tag-$language.nozomi"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val bytes = getRangedResponse(nozomiAddress, range)
|
|
|
|
|
val nozomi = mutableSetOf<Int>()
|
|
|
|
|
|
|
|
|
|
val arrayBuffer = ByteBuffer
|
|
|
|
|
.wrap(bytes)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
val arrayBuffer =
|
|
|
|
|
ByteBuffer
|
|
|
|
|
.wrap(bytes)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
|
|
|
|
|
while (arrayBuffer.hasRemaining())
|
|
|
|
|
nozomi.add(arrayBuffer.int)
|
|
|
|
|
@ -341,9 +382,10 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
return nozomi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val galleriesIndexVersion = SuspendLazy {
|
|
|
|
|
webClient.httpGet("$ltnBaseUrl/galleriesindex/version?_=${System.currentTimeMillis()}").parseRaw()
|
|
|
|
|
}
|
|
|
|
|
private val galleriesIndexVersion =
|
|
|
|
|
SuspendLazy {
|
|
|
|
|
webClient.httpGet("$ltnBaseUrl/galleriesindex/version?_=${System.currentTimeMillis()}").parseRaw()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private data class Node(
|
|
|
|
|
val keys: List<UByteArray>,
|
|
|
|
|
@ -351,10 +393,11 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
val subNodeAddresses: List<Long>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private fun decodeNode(data: ByteArray) : Node {
|
|
|
|
|
val buffer = ByteBuffer
|
|
|
|
|
.wrap(data)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
private fun decodeNode(data: ByteArray): Node {
|
|
|
|
|
val buffer =
|
|
|
|
|
ByteBuffer
|
|
|
|
|
.wrap(data)
|
|
|
|
|
.order(ByteOrder.BIG_ENDIAN)
|
|
|
|
|
|
|
|
|
|
val uData = data.toUByteArray()
|
|
|
|
|
|
|
|
|
|
@ -364,11 +407,12 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
for (i in 0.until(numberOfKeys)) {
|
|
|
|
|
val keySize = buffer.int
|
|
|
|
|
|
|
|
|
|
if (keySize == 0 || keySize > 32)
|
|
|
|
|
if (keySize == 0 || keySize > 32) {
|
|
|
|
|
throw Exception("fatal: !keySize || keySize > 32")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
keys.add(uData.sliceArray(buffer.position().until(buffer.position()+keySize)))
|
|
|
|
|
buffer.position(buffer.position()+keySize)
|
|
|
|
|
keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize)))
|
|
|
|
|
buffer.position(buffer.position() + keySize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val numberOfDatas = buffer.int
|
|
|
|
|
@ -392,7 +436,7 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
return Node(keys, datas, subNodeAddresses)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun getGalleryNodeAtAddress(address: Long) : Node {
|
|
|
|
|
private suspend fun getGalleryNodeAtAddress(address: Long): Node {
|
|
|
|
|
val url = "$ltnBaseUrl/galleriesindex/galleries.${galleriesIndexVersion.get()}.index"
|
|
|
|
|
|
|
|
|
|
val nodedata = getRangedResponse(url, address.until(address + 464))
|
|
|
|
|
@ -400,20 +444,24 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
return decodeNode(nodedata)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private suspend fun getRangedResponse(url: String, range: LongRange? = null) : ByteArray {
|
|
|
|
|
val rangeHeaders = when (range) {
|
|
|
|
|
null -> Headers.headersOf()
|
|
|
|
|
else -> Headers.headersOf("Range", "bytes=${range.first}-${range.last}")
|
|
|
|
|
}
|
|
|
|
|
private suspend fun getRangedResponse(
|
|
|
|
|
url: String,
|
|
|
|
|
range: LongRange? = null,
|
|
|
|
|
): ByteArray {
|
|
|
|
|
val rangeHeaders =
|
|
|
|
|
when (range) {
|
|
|
|
|
null -> Headers.headersOf()
|
|
|
|
|
else -> Headers.headersOf("Range", "bytes=${range.first}-${range.last}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return webClient.httpGet(url, rangeHeaders).parseBytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun hashTerm(term: String) : UByteArray {
|
|
|
|
|
private fun hashTerm(term: String): UByteArray {
|
|
|
|
|
return sha256(term.toByteArray()).copyOfRange(0, 4).toUByteArray()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun sha256(data: ByteArray) : ByteArray {
|
|
|
|
|
private fun sha256(data: ByteArray): ByteArray {
|
|
|
|
|
return MessageDigest.getInstance("SHA-256").digest(data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -428,12 +476,15 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
id = generateUid(id.toString()),
|
|
|
|
|
title = doc.selectFirstOrThrow("h1").text(),
|
|
|
|
|
url = id.toString(),
|
|
|
|
|
coverUrl = "https:" + doc.selectFirstOrThrow("picture > source")
|
|
|
|
|
.attr("data-srcset")
|
|
|
|
|
.substringBefore(" "),
|
|
|
|
|
publicUrl = doc.selectFirstOrThrow("h1 > a")
|
|
|
|
|
.attrAsRelativeUrl("href")
|
|
|
|
|
.toAbsoluteUrl(domain),
|
|
|
|
|
coverUrl =
|
|
|
|
|
"https:" +
|
|
|
|
|
doc.selectFirstOrThrow("picture > source")
|
|
|
|
|
.attr("data-srcset")
|
|
|
|
|
.substringBefore(" "),
|
|
|
|
|
publicUrl =
|
|
|
|
|
doc.selectFirstOrThrow("h1 > a")
|
|
|
|
|
.attrAsRelativeUrl("href")
|
|
|
|
|
.toAbsoluteUrl(domain),
|
|
|
|
|
author = null,
|
|
|
|
|
tags = emptySet(),
|
|
|
|
|
isNsfw = true,
|
|
|
|
|
@ -449,54 +500,59 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override suspend fun getDetails(manga: Manga): Manga {
|
|
|
|
|
val json = webClient.httpGet("$ltnBaseUrl/galleries/${manga.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
val json =
|
|
|
|
|
webClient.httpGet("$ltnBaseUrl/galleries/${manga.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
|
|
|
|
|
return manga.copy(
|
|
|
|
|
title = json.getString("title"),
|
|
|
|
|
largeCoverUrl = json.getJSONArray("files").getJSONObject(0).let {
|
|
|
|
|
val hash = it.getString("hash")
|
|
|
|
|
val commonId = commonImageId()
|
|
|
|
|
val imageId = imageIdFromHash(hash)
|
|
|
|
|
val subDomain = 'a' + subdomainOffset(imageId)
|
|
|
|
|
|
|
|
|
|
"https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
|
|
|
|
|
},
|
|
|
|
|
author = json.optJSONArray("artists")
|
|
|
|
|
?.mapJSON { it.getString("artist").toCamelCase() }
|
|
|
|
|
?.joinToString(),
|
|
|
|
|
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
|
|
|
|
|
tags = buildSet {
|
|
|
|
|
json.optJSONArray("characters")
|
|
|
|
|
?.mapToTags("character")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("tags")
|
|
|
|
|
?.mapToTags("tag")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
largeCoverUrl =
|
|
|
|
|
json.getJSONArray("files").getJSONObject(0).let {
|
|
|
|
|
val hash = it.getString("hash")
|
|
|
|
|
val commonId = commonImageId()
|
|
|
|
|
val imageId = imageIdFromHash(hash)
|
|
|
|
|
val subDomain = 'a' + subdomainOffset(imageId)
|
|
|
|
|
|
|
|
|
|
"https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
|
|
|
|
|
},
|
|
|
|
|
author =
|
|
|
|
|
json.optJSONArray("artists")
|
|
|
|
|
?.mapToTags("artist")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("parodys")
|
|
|
|
|
?.mapToTags("parody")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("groups")
|
|
|
|
|
?.mapToTags("group")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
},
|
|
|
|
|
chapters = listOf(
|
|
|
|
|
MangaChapter(
|
|
|
|
|
id = generateUid(manga.url),
|
|
|
|
|
url = manga.url,
|
|
|
|
|
name = json.getString("title"),
|
|
|
|
|
scanlator = json.getString("type").toTitleCase(),
|
|
|
|
|
number = 1,
|
|
|
|
|
branch = json.getString("language_localname"),
|
|
|
|
|
source = source,
|
|
|
|
|
uploadDate = dateFormat.tryParse(json.getString("date").substringBeforeLast("-")),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
?.mapJSON { it.getString("artist").toCamelCase() }
|
|
|
|
|
?.joinToString(),
|
|
|
|
|
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
|
|
|
|
|
tags =
|
|
|
|
|
buildSet {
|
|
|
|
|
json.optJSONArray("characters")
|
|
|
|
|
?.mapToTags("character")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("tags")
|
|
|
|
|
?.mapToTags("tag")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("artists")
|
|
|
|
|
?.mapToTags("artist")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("parodys")
|
|
|
|
|
?.mapToTags("parody")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
json.optJSONArray("groups")
|
|
|
|
|
?.mapToTags("group")
|
|
|
|
|
?.let(::addAll)
|
|
|
|
|
},
|
|
|
|
|
chapters =
|
|
|
|
|
listOf(
|
|
|
|
|
MangaChapter(
|
|
|
|
|
id = generateUid(manga.url),
|
|
|
|
|
url = manga.url,
|
|
|
|
|
name = json.getString("title"),
|
|
|
|
|
scanlator = json.getString("type").toTitleCase(),
|
|
|
|
|
number = 1,
|
|
|
|
|
branch = json.getString("language_localname"),
|
|
|
|
|
source = source,
|
|
|
|
|
uploadDate = dateFormat.tryParse(json.getString("date").substringBeforeLast("-")),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -508,17 +564,18 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
val tags = mutableSetOf<MangaTag>()
|
|
|
|
|
mapJSON {
|
|
|
|
|
MangaTag(
|
|
|
|
|
title = it.getString(key).toCamelCase().let { title ->
|
|
|
|
|
if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
|
|
|
|
|
"$title ♀"
|
|
|
|
|
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
|
|
|
|
|
"$title ♂"
|
|
|
|
|
} else {
|
|
|
|
|
title
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
title =
|
|
|
|
|
it.getString(key).toCamelCase().let { title ->
|
|
|
|
|
if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
|
|
|
|
|
"$title ♀"
|
|
|
|
|
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
|
|
|
|
|
"$title ♂"
|
|
|
|
|
} else {
|
|
|
|
|
title
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
key = it.getString("url").tagUrlToTag(),
|
|
|
|
|
source = source
|
|
|
|
|
source = source,
|
|
|
|
|
).let(tags::add)
|
|
|
|
|
}
|
|
|
|
|
return tags
|
|
|
|
|
@ -527,10 +584,11 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
private fun String.tagUrlToTag(): String {
|
|
|
|
|
val urlContent = this.split("/")
|
|
|
|
|
val ns = urlContent[1]
|
|
|
|
|
val tag = urlContent[2]
|
|
|
|
|
.substringBeforeLast("-")
|
|
|
|
|
.urlDecode()
|
|
|
|
|
.replace(" ", "_")
|
|
|
|
|
val tag =
|
|
|
|
|
urlContent[2]
|
|
|
|
|
.substringBeforeLast("-")
|
|
|
|
|
.urlDecode()
|
|
|
|
|
.replace(" ", "_")
|
|
|
|
|
|
|
|
|
|
return if (tag.split(":")[0] in listOf("female", "male")) {
|
|
|
|
|
tag
|
|
|
|
|
@ -540,10 +598,11 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
|
|
|
|
val json = webClient.httpGet("$ltnBaseUrl/galleries/${seed.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
val json =
|
|
|
|
|
webClient.httpGet("$ltnBaseUrl/galleries/${seed.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
|
|
|
|
|
// any better way to get List<Int> from this json?
|
|
|
|
|
return json.getJSONArray("related").let {
|
|
|
|
|
@ -552,10 +611,11 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
|
|
|
val json = webClient.httpGet("$ltnBaseUrl/galleries/${chapter.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
val json =
|
|
|
|
|
webClient.httpGet("$ltnBaseUrl/galleries/${chapter.url}.js")
|
|
|
|
|
.parseRaw()
|
|
|
|
|
.substringAfter("var galleryinfo = ")
|
|
|
|
|
.let(::JSONObject)
|
|
|
|
|
|
|
|
|
|
return json.getJSONArray("files").mapJSON { image ->
|
|
|
|
|
val hash = image.getString("hash")
|
|
|
|
|
@ -564,15 +624,15 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
val subDomain = 'a' + subdomainOffset(imageId)
|
|
|
|
|
|
|
|
|
|
MangaPage(
|
|
|
|
|
id= generateUid(hash),
|
|
|
|
|
id = generateUid(hash),
|
|
|
|
|
url = "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp",
|
|
|
|
|
preview = "https://${getDomain("${subDomain}tn")}/webpsmalltn/${thumbPathFromHash(hash)}/$hash.webp",
|
|
|
|
|
source = source
|
|
|
|
|
source = source,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// --->
|
|
|
|
|
// / --->
|
|
|
|
|
|
|
|
|
|
private var scriptLastRetrieval: Long? = null
|
|
|
|
|
private val mutex = Mutex()
|
|
|
|
|
@ -580,24 +640,25 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
private val subdomainOffsetMap = mutableMapOf<Int, Int>()
|
|
|
|
|
private var commonImageId = ""
|
|
|
|
|
|
|
|
|
|
private suspend fun refreshScript() = mutex.withLock {
|
|
|
|
|
if (scriptLastRetrieval == null || (scriptLastRetrieval!! + 60000) < System.currentTimeMillis()) {
|
|
|
|
|
val ggScript = webClient.httpGet("$ltnBaseUrl/gg.js?_=${System.currentTimeMillis()}").parseRaw()
|
|
|
|
|
private suspend fun refreshScript() =
|
|
|
|
|
mutex.withLock {
|
|
|
|
|
if (scriptLastRetrieval == null || (scriptLastRetrieval!! + 60000) < System.currentTimeMillis()) {
|
|
|
|
|
val ggScript = webClient.httpGet("$ltnBaseUrl/gg.js?_=${System.currentTimeMillis()}").parseRaw()
|
|
|
|
|
|
|
|
|
|
subdomainOffsetDefault = Regex("var o = (\\d)").find(ggScript)!!.groupValues[1].toInt()
|
|
|
|
|
val o = Regex("o = (\\d); break;").find(ggScript)!!.groupValues[1].toInt()
|
|
|
|
|
subdomainOffsetDefault = Regex("var o = (\\d)").find(ggScript)!!.groupValues[1].toInt()
|
|
|
|
|
val o = Regex("o = (\\d); break;").find(ggScript)!!.groupValues[1].toInt()
|
|
|
|
|
|
|
|
|
|
subdomainOffsetMap.clear()
|
|
|
|
|
Regex("case (\\d+):").findAll(ggScript).forEach {
|
|
|
|
|
val case = it.groupValues[1].toInt()
|
|
|
|
|
subdomainOffsetMap[case] = o
|
|
|
|
|
}
|
|
|
|
|
subdomainOffsetMap.clear()
|
|
|
|
|
Regex("case (\\d+):").findAll(ggScript).forEach {
|
|
|
|
|
val case = it.groupValues[1].toInt()
|
|
|
|
|
subdomainOffsetMap[case] = o
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
commonImageId = Regex("b: '(.+)'").find(ggScript)!!.groupValues[1]
|
|
|
|
|
commonImageId = Regex("b: '(.+)'").find(ggScript)!!.groupValues[1]
|
|
|
|
|
|
|
|
|
|
scriptLastRetrieval = System.currentTimeMillis()
|
|
|
|
|
scriptLastRetrieval = System.currentTimeMillis()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// m <-- gg.js
|
|
|
|
|
private suspend fun subdomainOffset(imageId: Int): Int {
|
|
|
|
|
@ -614,7 +675,7 @@ class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
|
|
|
|
|
// s <-- gg.js
|
|
|
|
|
private fun imageIdFromHash(hash: String): Int {
|
|
|
|
|
val match = Regex("(..)(.)$").find(hash)
|
|
|
|
|
return match!!.groupValues.let { it[2]+it[1] }.toInt(16)
|
|
|
|
|
return match!!.groupValues.let { it[2] + it[1] }.toInt(16)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// real_full_path_from_hash <-- common.js
|
|
|
|
|
|