From 12374fb573d0e11082d87191060745759cf9b226 Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Mon, 9 Jun 2025 23:56:49 +0800 Subject: [PATCH] Migrate `GalleryDetailParser` to Rust Part 1/N --- .../com/hippo/ehviewer/client/EhEngine.kt | 42 +- .../ehviewer/client/data/GalleryComment.kt | 3 + .../client/data/GalleryCommentList.kt | 3 + .../ehviewer/client/data/GalleryDetail.kt | 29 +- .../ehviewer/client/data/GalleryPreview.kt | 7 + .../hippo/ehviewer/client/data/GalleryTag.kt | 15 +- .../ehviewer/client/data/GalleryTagGroup.kt | 5 +- .../ehviewer/client/data/TagNamespace.kt | 18 + .../ehviewer/client/parser/EventPaneParser.kt | 6 +- .../client/parser/GalleryDetailParser.kt | 70 +-- .../parser/GalleryNotAvailableParser.kt | 33 -- .../com/hippo/ehviewer/spider/ComicInfo.kt | 4 +- .../com/hippo/ehviewer/ui/main/GalleryTag.kt | 4 +- .../ui/screen/GalleryCommentsScreen.kt | 7 +- .../ui/screen/GalleryDetailContent.kt | 7 +- app/src/main/rust/Cargo.lock | 1 + app/src/main/rust/Cargo.toml | 1 + app/src/main/rust/src/ffi/jvm.rs | 41 +- app/src/main/rust/src/lib.rs | 10 + app/src/main/rust/src/parser.rs | 1 + app/src/main/rust/src/parser/detail.rs | 507 ++++++++++++++++++ app/src/main/rust/src/parser/list.rs | 48 +- app/src/main/rust/src/parser/test_parser.rs | 13 + 23 files changed, 727 insertions(+), 148 deletions(-) delete mode 100644 app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryNotAvailableParser.kt create mode 100644 app/src/main/rust/src/parser/detail.rs diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt index 8bca1ecdd..4ab988ef8 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt @@ -41,7 +41,6 @@ import com.hippo.ehviewer.client.parser.FavoritesParser import com.hippo.ehviewer.client.parser.GalleryApiParser import com.hippo.ehviewer.client.parser.GalleryDetailParser import com.hippo.ehviewer.client.parser.GalleryListParser -import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser import com.hippo.ehviewer.client.parser.GalleryPageParser import com.hippo.ehviewer.client.parser.GalleryTokenApiParser import com.hippo.ehviewer.client.parser.HomeParser @@ -121,11 +120,19 @@ fun rethrowExactly(response: HttpResponse, body: Either, e: // Check Gallery Not Available body.onLeft { - if ("Gallery Not Available - " in it) { - val error = GalleryNotAvailableParser.parse(it) - if (!error.isNullOrBlank()) { - throw EhException(error) - } + if ("Gallery Not Available - " in it) throw e + } + + // Check parse error thrown by rust + val cause = e.cause + if (e is ParseException && cause is RuntimeException) { + when (val message = cause.message) { + "0" -> throw NoHitsFoundException() + "1" -> throw EhException(R.string.gallery_list_empty_hit_subscription) + "2" -> throw NotLoggedInException() + "3" -> throw NoHathClientException() + "4" -> throw InsufficientFundsException() + is String if message.startsWith('5') -> throw EhException(message.drop(1)) } } @@ -134,14 +141,6 @@ fun rethrowExactly(response: HttpResponse, body: Either, e: if (e is ParseException || e is SerializationException) { body.onLeft { if ("<" !in it) throw EhException(it) } - when (val message = e.cause?.message) { - "0" -> throw NoHitsFoundException() - "1" -> throw EhException(R.string.gallery_list_empty_hit_subscription) - "2" -> throw NotLoggedInException() - "3" -> throw NoHathClientException() - "4" -> throw InsufficientFundsException() - is String if message.startsWith('5') -> throw EhException(message.drop(1)) - } if (Settings.saveParseErrorBody) body.saveParseError(e) throw EhException(appCtx.getString(R.string.error_parse_error), e) } @@ -205,7 +204,7 @@ object EhEngine { private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E) - .fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null } + .fetchUsingAsByteBuffer { if (parse) EventPaneParser.parse(this) else null } suspend fun getProfile(): ProfileParser.Result { val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parseProfileUrl) @@ -235,13 +234,16 @@ object EhEngine { .apply { galleryInfoList.fillInfo(url, true) } .takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult - suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText { - val eventPane = EventPaneParser.parse(this) - if (eventPane != null) { + suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parseRust).run { + event?.let { Settings.lastDawnDays = today - showEventNotification(eventPane) + showEventNotification(it) + } + detail.apply { + // Fill info for database + fillInfo() + filterComments() } - GalleryDetailParser.parse(this) } suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryComment.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryComment.kt index 8941f4d2d..8d818ec62 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryComment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryComment.kt @@ -15,6 +15,9 @@ */ package com.hippo.ehviewer.client.data +import kotlinx.serialization.Serializable + +@Serializable data class GalleryComment( val id: Long, val score: Int, diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt index 71fbd18bb..32ba7d830 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryCommentList.kt @@ -15,6 +15,9 @@ */ package com.hippo.ehviewer.client.data +import kotlinx.serialization.Serializable + +@Serializable data class GalleryCommentList( val comments: List, val hasMore: Boolean, diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryDetail.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryDetail.kt index e50aaed91..afef0b39f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryDetail.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryDetail.kt @@ -16,7 +16,10 @@ package com.hippo.ehviewer.client.data import androidx.compose.ui.util.fastFlatMap -import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS +import com.hippo.ehviewer.EhDB +import com.hippo.ehviewer.Settings +import com.hippo.ehviewer.client.EhFilter +import kotlinx.serialization.Serializable private val LANGUAGES = arrayOf( "English", @@ -35,6 +38,7 @@ private val LANGUAGES = arrayOf( "Dutch", ) +@Serializable data class GalleryDetail( val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(), var apiUid: Long = -1L, @@ -53,11 +57,28 @@ data class GalleryDetail( var comments: GalleryCommentList, val previewList: List, ) : GalleryInfo by galleryInfo { - fun fillInfo() { + suspend fun fillInfo() { val index = LANGUAGES.indexOf(language) - if (index != -1) simpleLanguage = S_LANGS[index] + if (index != -1) simpleLanguage = GalleryInfo.S_LANGS[index] simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags).map { (text, power, _) -> - if (power == PowerStatus.WEAK) "_$text" else text + if (power == PowerStatus.Weak) "_$text" else text + } + if (favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(gid)) { + favoriteSlot = GalleryInfo.LOCAL_FAVORITED + } + } + + suspend fun filterComments() { + comments = with(comments) { + val scoreThreshold = Settings.commentThreshold + copy( + comments = comments.filter { + it.uploader || + it.score > scoreThreshold && + !EhFilter.filterCommenter(it.user.orEmpty()) && + !EhFilter.filterComment(it.comment) + }, + ) } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryPreview.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryPreview.kt index f9b181e68..52459798f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryPreview.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryPreview.kt @@ -17,7 +17,10 @@ package com.hippo.ehviewer.client.data import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.client.getV2PreviewKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable sealed interface GalleryPreview { val url: String val imageKey: String @@ -25,6 +28,8 @@ sealed interface GalleryPreview { val pToken: String } +@Serializable +@SerialName("V1") data class V1GalleryPreview( override val url: String, override val position: Int, @@ -33,6 +38,8 @@ data class V1GalleryPreview( override val imageKey get() = getThumbKey(url) } +@Serializable +@SerialName("V2") data class V2GalleryPreview( override val url: String, override val position: Int, diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTag.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTag.kt index 7abd23d15..7e2fc59eb 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTag.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTag.kt @@ -1,17 +1,20 @@ package com.hippo.ehviewer.client.data +import kotlinx.serialization.Serializable + enum class VoteStatus(val display: String) { - NONE(""), - UP("+1"), - DOWN("-1"), + None(""), + Up("+1"), + Down("-1"), } enum class PowerStatus { - SOLID, - ACTIVE, - WEAK, + Solid, + Active, + Weak, } +@Serializable data class GalleryTag( val text: String, val power: PowerStatus, diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTagGroup.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTagGroup.kt index e05cfebcc..4762fa883 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTagGroup.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/GalleryTagGroup.kt @@ -15,7 +15,10 @@ */ package com.hippo.ehviewer.client.data +import kotlinx.serialization.Serializable + +@Serializable data class GalleryTagGroup( - val nameSpace: TagNamespace, + val namespace: TagNamespace, val tags: List, ) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/TagNamespace.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/TagNamespace.kt index 10c2bc253..dbc16739a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/TagNamespace.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/TagNamespace.kt @@ -1,5 +1,14 @@ package com.hippo.ehviewer.client.data +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(TagNamespaceSerializer::class) sealed class TagNamespace(val value: String, val prefix: String?) { object Artist : TagNamespace("artist", "a") object Cosplayer : TagNamespace("cosplayer", "cos") @@ -32,3 +41,12 @@ sealed class TagNamespace(val value: String, val prefix: String?) { } } } + +object TagNamespaceSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("TagNamespace", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): TagNamespace { + val string = decoder.decodeString() + return TagNamespace.from(string) ?: throw SerializationException("Unknown tag namespace $string") + } + override fun serialize(encoder: Encoder, value: TagNamespace) = encoder.encodeString(value.value) +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/EventPaneParser.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/EventPaneParser.kt index b1735e052..6d585907b 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/EventPaneParser.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/EventPaneParser.kt @@ -17,8 +17,10 @@ */ package com.hippo.ehviewer.client.parser -import org.jsoup.Jsoup +import java.nio.ByteBuffer object EventPaneParser { - fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html() + private external fun parseEventPane(body: ByteBuffer, size: Int = body.limit()): Int + + fun parse(body: ByteBuffer): String? = unmarshalParsingAs(body, ::parseEventPane) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt index 1d1553c69..98461868f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt @@ -17,16 +17,11 @@ package com.hippo.ehviewer.client.parser import arrow.core.Either import arrow.core.getOrElse -import com.hippo.ehviewer.EhDB -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.client.EhFilter import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryComment import com.hippo.ehviewer.client.data.GalleryCommentList import com.hippo.ehviewer.client.data.GalleryDetail -import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED -import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED import com.hippo.ehviewer.client.data.GalleryTag import com.hippo.ehviewer.client.data.GalleryTagGroup import com.hippo.ehviewer.client.data.PowerStatus @@ -35,9 +30,7 @@ import com.hippo.ehviewer.client.data.V1GalleryPreview import com.hippo.ehviewer.client.data.V2GalleryPreview import com.hippo.ehviewer.client.data.VoteStatus import com.hippo.ehviewer.client.exception.EhException -import com.hippo.ehviewer.client.exception.OffensiveException import com.hippo.ehviewer.client.exception.ParseException -import com.hippo.ehviewer.client.exception.PiningException import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.util.toEpochMillis import com.hippo.ehviewer.util.toFloatOrDefault @@ -45,16 +38,19 @@ import com.hippo.ehviewer.util.toIntOrDefault import com.hippo.ehviewer.util.trimAnd import com.hippo.ehviewer.util.unescapeXml import eu.kanade.tachiyomi.util.system.logcat +import java.nio.ByteBuffer import kotlinx.datetime.LocalDateTime import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.char +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.CborArray import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.select.NodeTraversor object GalleryDetailParser { - private val PATTERN_ERROR = Regex("
\n

([^<]+)

") + private val PATTERN_ERROR = Regex("
[^<]*

([^<]+)

") private val PATTERN_DETAIL = Regex("var gid = (\\d+);\\s+var token = \"([a-f0-9]+)\";\\s+var apiuid = ([\\-\\d]+);\\s+var apikey = \"([a-f0-9]+)\";") private val PATTERN_TORRENT = @@ -83,20 +79,9 @@ object GalleryDetailParser { char(':') minute() } - private const val OFFENSIVE_STRING = - "

(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)

" - private const val PINING_STRING = "

This gallery is pining for the fjords.

" - suspend fun parse(body: String): GalleryDetail { - if (body.contains(OFFENSIVE_STRING)) { - throw OffensiveException() - } - if (body.contains(PINING_STRING)) { - throw PiningException() - } - - // Error info - PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) } + fun parse(body: String): GalleryDetail { + PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1].replace("
", "\n")) } val document = Jsoup.parse(body) val galleryDetail = GalleryDetail( tagGroups = parseTagGroups(document), @@ -104,13 +89,10 @@ object GalleryDetailParser { previewList = parsePreviewList(body), ) parseDetail(galleryDetail, document, body) - - // Fill info for database - galleryDetail.fillInfo() return galleryDetail } - private suspend fun parseDetail(gd: GalleryDetail, d: Document, body: String) { + private fun parseDetail(gd: GalleryDetail, d: Document, body: String) { PATTERN_DETAIL.find(body)?.apply { gd.gid = groupValues[1].toLongOrNull() ?: -1L gd.token = groupValues[2] @@ -206,9 +188,6 @@ object GalleryDetailParser { } } } - if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) { - gd.favoriteSlot = LOCAL_FAVORITED - } }.getOrElse { throw ParseException("Can't parse gallery detail") } @@ -279,14 +258,14 @@ object GalleryDetailParser { // Sometimes parody tag is followed with '|' and english translate, just remove them text = e.text().substringBefore('|').trim(), power = when { - e.hasClass("gtw") -> PowerStatus.WEAK - e.hasClass("gtl") -> PowerStatus.ACTIVE - else -> PowerStatus.SOLID + e.hasClass("gtw") -> PowerStatus.Weak + e.hasClass("gtl") -> PowerStatus.Active + else -> PowerStatus.Solid }, vote = when { - e.child(0).hasClass("tup") -> VoteStatus.UP - e.child(0).hasClass("tdn") -> VoteStatus.DOWN - else -> VoteStatus.NONE + e.child(0).hasClass("tup") -> VoteStatus.Up + e.child(0).hasClass("tdn") -> VoteStatus.Down + else -> VoteStatus.None }, ) } @@ -308,7 +287,7 @@ object GalleryDetailParser { emptyList() } - private suspend fun parseComment(element: Element): GalleryComment? = Either.catch { + private fun parseComment(element: Element): GalleryComment? = Either.catch { // Id val a = element.previousElementSibling() val name = a!!.attr("name") @@ -365,13 +344,6 @@ object GalleryDetailParser { } } val commentHtml = c6.html() - // filter comment - if (!uploader) { - val sEhFilter = EhFilter - if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) { - return null - } - } // last edited val c8 = element.getElementsByClass("c8").first() val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0 @@ -387,7 +359,7 @@ object GalleryDetailParser { /** * Parse comments with html parser */ - suspend fun parseComments(document: Document): GalleryCommentList = Either.catch { + fun parseComments(document: Document): GalleryCommentList = Either.catch { // Disable pretty print to get comments in raw html document.outputSettings().prettyPrint(false) val cdiv = document.getElementById("cdiv")!! @@ -430,4 +402,16 @@ object GalleryDetailParser { }.toList().apply { if (isEmpty()) throw ParseException("Parse preview list error") } + + fun parseRust(body: ByteBuffer) = Either.catch { + unmarshalParsingAs(body, ::parseGalleryDetail) + }.getOrElse { + throw ParseException("Failed to parse gallery detail", it) + } + + @Serializable + @CborArray + class Result(val detail: GalleryDetail, val event: String?) + + private external fun parseGalleryDetail(body: ByteBuffer, size: Int = body.limit()): Int } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryNotAvailableParser.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryNotAvailableParser.kt deleted file mode 100644 index e6855c933..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryNotAvailableParser.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 Tarsin Norbin - * - * This file is part of EhViewer - * - * EhViewer is free software: you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * EhViewer is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with EhViewer. - * If not, see . - */ -package com.hippo.ehviewer.client.parser - -import arrow.core.Either -import arrow.core.getOrElse -import eu.kanade.tachiyomi.util.system.logcat -import org.jsoup.Jsoup - -object GalleryNotAvailableParser { - fun parse(body: String): String? = Either.catch { - val e = Jsoup.parse(body).getElementsByClass("d").first() - e!!.child(0).html().replace("
", "\n") - }.getOrElse { - logcat(it) - null - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/spider/ComicInfo.kt b/app/src/main/kotlin/com/hippo/ehviewer/spider/ComicInfo.kt index 1d49f67e8..22ec4cbca 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/spider/ComicInfo.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/spider/ComicInfo.kt @@ -54,8 +54,8 @@ fun GalleryInfo.getComicInfo(): ComicInfo { with(TagNamespace) { when (this@getComicInfo) { is GalleryDetail -> tagGroups.forEach { group -> - val list = group.tags.filterNot { (text, power, _) -> text == TAG_ORIGINAL || power == PowerStatus.WEAK }.map(GalleryTag::text) - when (val ns = group.nameSpace) { + val list = group.tags.filterNot { (text, power, _) -> text == TAG_ORIGINAL || power == PowerStatus.Weak }.map(GalleryTag::text) + when (val ns = group.namespace) { Artist, Cosplayer -> artists.addAll(list) Group -> groups.addAll(list) Character -> characters.addAll(list) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryTag.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryTag.kt index 6094d8381..6c4bebd87 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryTag.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryTag.kt @@ -58,7 +58,7 @@ fun GalleryTags( Box { BaseRoundText( text = translation, - weak = power == PowerStatus.WEAK, + weak = power == PowerStatus.Weak, modifier = Modifier.combinedClickable( onClick = { onTagClick(tag) }, onLongClick = { @@ -67,7 +67,7 @@ fun GalleryTags( }, ), ) - if (vote != VoteStatus.NONE && showVote) { + if (vote != VoteStatus.None && showVote) { Text( text = vote.display, modifier = Modifier.align(Alignment.TopEnd).padding(horizontal = 2.dp), diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryCommentsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryCommentsScreen.kt index 2529600d3..23ec9facc 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryCommentsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryCommentsScreen.kt @@ -243,9 +243,8 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat snackbar(filterAdded) } - suspend fun showCommentVoteStatus(comment: GalleryComment) { - val statusStr = comment.voteState ?: return - val data = statusStr.split(',').map { + suspend fun showCommentVoteStatus(status: String) { + val data = status.split(',').map { val str = it.trim() val index = str.lastIndexOf(' ') if (index < 0) { @@ -400,7 +399,7 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat } if (!comment.voteState.isNullOrEmpty()) { onSelect(checkVoteStatus) { - showCommentVoteStatus(comment) + showCommentVoteStatus(comment.voteState) } } }() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt index 0a3875e10..c4cace82e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt @@ -743,12 +743,12 @@ fun BelowHeader(galleryDetail: GalleryDetail, voteTag: VoteTag) { } if (galleryDetail.apiUid >= 0) { when (vote) { - VoteStatus.NONE -> { + VoteStatus.None -> { onSelect(upTag) { galleryDetail.voteTag(tag, 1) } onSelect(downTag) { galleryDetail.voteTag(tag, -1) } } - VoteStatus.UP -> onSelect(withDraw) { galleryDetail.voteTag(tag, -1) } - VoteStatus.DOWN -> onSelect(withDraw) { galleryDetail.voteTag(tag, 1) } + VoteStatus.Up -> onSelect(withDraw) { galleryDetail.voteTag(tag, -1) } + VoteStatus.Down -> onSelect(withDraw) { galleryDetail.voteTag(tag, 1) } } } }() @@ -765,7 +765,6 @@ fun BelowHeader(galleryDetail: GalleryDetail, voteTag: VoteTag) { @StringRes private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) { - 0 -> R.string.rating0 1 -> R.string.rating1 2 -> R.string.rating2 3 -> R.string.rating3 diff --git a/app/src/main/rust/Cargo.lock b/app/src/main/rust/Cargo.lock index 736c895dc..34feffaf8 100644 --- a/app/src/main/rust/Cargo.lock +++ b/app/src/main/rust/Cargo.lock @@ -269,6 +269,7 @@ version = "0.0.0" dependencies = [ "android_logger", "anyhow", + "chrono", "image", "jni", "jni_fn", diff --git a/app/src/main/rust/Cargo.toml b/app/src/main/rust/Cargo.toml index bd30bf0d4..5a3cf8052 100644 --- a/app/src/main/rust/Cargo.toml +++ b/app/src/main/rust/Cargo.toml @@ -15,6 +15,7 @@ android-26 = ["android", "ndk/api-level-26"] regex-lite = "0.1" jni = { version = "0.21", optional = true } jni_fn = { version = "0.1", optional = true } +chrono = { version = "0.4", default-features = false, features = ["std"] } quick-xml = { default-features = false, version = "0.37" } tl = { version = "0.7", features = ["simd"] } log = "0.4" diff --git a/app/src/main/rust/src/ffi/jvm.rs b/app/src/main/rust/src/ffi/jvm.rs index 41b2cd601..ae012919f 100644 --- a/app/src/main/rust/src/ffi/jvm.rs +++ b/app/src/main/rust/src/ffi/jvm.rs @@ -2,6 +2,7 @@ use crate::parser::archive::{parse_archive_url, parse_archives, parse_archives_with_funds}; use crate::parser::config::parse_fav_cat; +use crate::parser::detail::{parse_event_pane, parse_gallery_detail}; use crate::parser::fav::parse_fav; use crate::parser::home::parse_limit; use crate::parser::list::parse_info_list; @@ -49,6 +50,25 @@ pub fn parseGalleryInfoList(mut env: JNIEnv, _: JClass, buffer: JByteBuffer, lim }) } +#[no_mangle] +#[allow(non_snake_case)] +#[jni_fn("com.hippo.ehviewer.client.parser.GalleryDetailParser")] +pub fn parseGalleryDetail(mut env: JNIEnv, _: JClass, buffer: JByteBuffer, limit: jint) -> jint { + let options = ParserOptions::default().track_ids(); + parse_marshal_inplace_with_options(&mut env, buffer, limit, options, |dom, html| { + parse_gallery_detail(dom, html).map(|detail| (detail, parse_event_pane(dom, dom.parser()))) + }) +} + +#[no_mangle] +#[allow(non_snake_case)] +#[jni_fn("com.hippo.ehviewer.client.parser.EventPaneParser")] +pub fn parseEventPane(mut env: JNIEnv, _class: JClass, input: JByteBuffer, limit: jint) -> jint { + parse_marshal_inplace(&mut env, input, limit, |dom, _| { + Ok(parse_event_pane(dom, dom.parser())) + }) +} + #[no_mangle] #[allow(non_snake_case)] #[jni_fn("com.hippo.ehviewer.client.parser.TorrentParserKt")] @@ -165,9 +185,24 @@ where } } +#[inline] pub fn parse_marshal_inplace(env: &mut JNIEnv, str: JByteBuffer, limit: jint, f: F) -> i32 where - F: Fn(&VDom, &str) -> Result, + F: Fn(&mut VDom, &str) -> Result, + R: Serialize, +{ + parse_marshal_inplace_with_options(env, str, limit, ParserOptions::default(), f) +} + +pub fn parse_marshal_inplace_with_options( + env: &mut JNIEnv, + str: JByteBuffer, + limit: jint, + options: ParserOptions, + f: F, +) -> i32 +where + F: Fn(&mut VDom, &str) -> Result, R: Serialize, { jni_throwing(env, |env| { @@ -176,9 +211,9 @@ where // SAFETY: ktor client ensure html content is valid utf-8. let html = unsafe { from_utf8_unchecked(&buffer[..limit as usize]) }; - let dom = tl::parse(html, ParserOptions::default())?; + let mut dom = tl::parse(html, options)?; ensure!(dom.version().is_some(), EhError::Error(html.to_string())); - f(&dom, html)? + f(&mut dom, html)? }; let mut cursor = Cursor::new(buffer); serde_cbor::to_writer(&mut cursor, &value)?; diff --git a/app/src/main/rust/src/lib.rs b/app/src/main/rust/src/lib.rs index 905dfa449..9c1cabe55 100644 --- a/app/src/main/rust/src/lib.rs +++ b/app/src/main/rust/src/lib.rs @@ -36,6 +36,8 @@ fn get_first_element_by_class_name<'a>( handle.get(parser) } +// tl does not support selecting child elements +// Workaround for https://github.com/y21/tl/issues/22 fn select_first<'a>(tag: &'a HTMLTag, parser: &'a Parser, id: &str) -> Option<&'a HTMLTag<'a>> { for n in tag.children().top().iter() { let tag = n.get(parser).and_then(Node::as_tag); @@ -46,6 +48,14 @@ fn select_first<'a>(tag: &'a HTMLTag, parser: &'a Parser, id: &str) -> Option<&' None } +fn get_first_child<'a>(tag: &'a HTMLTag, parser: &'a Parser) -> Option<&'a HTMLTag<'a>> { + tag.children() + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()) + .next() +} + fn get_element_by_id<'b, S>(node: &'b Node, parser: &'b Parser, id: S) -> Option<&'b Node<'b>> where S: Into>, diff --git a/app/src/main/rust/src/parser.rs b/app/src/main/rust/src/parser.rs index 8a0a2753b..59662692f 100644 --- a/app/src/main/rust/src/parser.rs +++ b/app/src/main/rust/src/parser.rs @@ -1,5 +1,6 @@ pub mod archive; pub mod config; +pub mod detail; pub mod fav; pub mod home; pub mod list; diff --git a/app/src/main/rust/src/parser/detail.rs b/app/src/main/rust/src/parser/detail.rs new file mode 100644 index 000000000..dc72f085e --- /dev/null +++ b/app/src/main/rust/src/parser/detail.rs @@ -0,0 +1,507 @@ +use crate::parser::list::{get_thumb_key, parse_token_and_gid, to_category_i32, BaseGalleryInfo}; +use crate::{get_first_child, get_first_element_by_class_name, get_node_handle_attr, get_tag_attr}; +use crate::{get_vdom_first_element_by_class_name, regex, EhError}; +use anyhow::{bail, Context, Result}; +use chrono::naive::NaiveDateTime; +use quick_xml::escape::unescape; +use serde::Serialize; +use tl::{Node, NodeHandle, Parser, VDom}; + +#[derive(Serialize, Debug, Default)] +#[allow(non_snake_case)] +pub struct GalleryDetail { + pub galleryInfo: BaseGalleryInfo, + pub apiUid: i64, + pub apiKey: Option, + pub torrentCount: i32, + pub torrentUrl: Option, + pub archiveUrl: Option, + pub parent: Option, + pub newerVersions: Vec, + pub visible: Option, + pub language: Option, + pub size: Option, + pub favoriteCount: i32, + pub ratingCount: i32, + pub tagGroups: Vec, + pub comments: GalleryCommentList, + pub previewList: Vec, +} + +#[derive(Serialize, Debug)] +pub enum PowerStatus { + Solid, + Weak, + Active, +} + +#[derive(Serialize, Debug)] +pub enum VoteStatus { + None, + Up, + Down, +} + +#[derive(Serialize, Debug)] +pub struct GalleryTag { + pub text: String, + pub power: PowerStatus, + pub vote: VoteStatus, +} + +#[derive(Serialize, Debug)] +pub struct GalleryTagGroup { + pub namespace: String, + pub tags: Vec, +} + +#[derive(Serialize, Debug)] +#[allow(non_snake_case)] +pub struct GalleryComment { + pub id: i64, + pub score: i32, + pub editable: bool, + pub voteUpAble: bool, + pub voteUpEd: bool, + pub voteDownAble: bool, + pub voteDownEd: bool, + pub uploader: bool, + pub voteState: Option, + pub time: i64, + pub user: Option, + pub comment: String, + pub lastEdited: i64, +} + +#[derive(Serialize, Debug, Default)] +#[allow(non_snake_case)] +pub struct GalleryCommentList { + pub comments: Vec, + pub hasMore: bool, +} + +#[derive(Serialize, Debug)] +#[allow(non_snake_case)] +pub struct PreviewData { + pub url: String, + pub position: i32, + pub pToken: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub offsetX: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clipWidth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clipHeight: Option, +} + +// Should use internally tagged enum, but kotlinx.serialization only support array polymorphism for CBOR +// https://github.com/Kotlin/kotlinx.serialization/issues/2058 +#[derive(Serialize, Debug)] +pub struct GalleryPreview(pub String, pub PreviewData); + +fn parse_detail_info(gd: &mut GalleryDetail, node: &NodeHandle, parser: &Parser) -> Option<()> { + let children = node.get(parser)?.children()?; + let mut iter = children + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()); + + let key_tag = iter.next()?; + let value_tag = iter.next()?; + + let key = key_tag.inner_text(parser); + let value = value_tag.children().top()[0] + .get(parser)? + .inner_text(parser); + + match key.as_ref() { + "Posted:" => gd.galleryInfo.posted = value.to_string(), + "Parent:" => { + gd.parent = get_first_child(value_tag, parser) + .and_then(|a| get_tag_attr(a, "href").map(str::to_string)) + } + "Visible:" => gd.visible = Some(value.to_string()), + "Language:" => gd.language = value.strip_suffix("  ").map(str::to_string), + "File Size:" => gd.size = Some(value.to_string()), + "Length:" => gd.galleryInfo.pages = value.strip_suffix(" pages")?.parse().ok()?, + "Favorited:" => { + gd.favoriteCount = match value.as_ref() { + "Never" => 0, + "Once" => 1, + _ => value.strip_suffix(" times")?.parse().ok()?, + } + } + _ => {} + } + + Some(()) +} + +fn parse_detail(gd: &mut GalleryDetail, dom: &VDom, parser: &Parser, body: &str) -> Option<()> { + let detail_regex = regex!( + r#"var gid = (\d+);\s*var token = "([a-f0-9]+)";\s*var apiuid = ([-\d]+);\s*var apikey = "([a-f0-9]+)";\s*var average_rating = ([.\d]+);"# + ); + let caps = detail_regex.captures(body)?; + gd.galleryInfo.gid = caps[1].parse().ok()?; + gd.galleryInfo.token = caps[2].to_string(); + gd.apiUid = caps[3].parse().ok()?; + gd.apiKey = Some(caps[4].to_string()); + gd.galleryInfo.rating = caps[5].parse().ok()?; + + let torrent_regex = regex!( + r#"]*onclick="return popUp\('([^']+)'[^)]+\)">Torrent Download \((\d+)\)"# + ); + let caps = torrent_regex.captures(body)?; + gd.torrentUrl = unescape(&caps[1]).ok().map(|s| s.to_string()); + gd.torrentCount = caps[2].parse().ok()?; + + let archive_regex = + regex!(r#"]*onclick="return popUp\('([^']+)'[^)]+\)">Archive Download"#); + let caps = archive_regex.captures(body)?; + gd.archiveUrl = unescape(&caps[1]).ok().map(|s| s.to_string()); + + let gd1 = dom.get_element_by_id("gd1")?.get(parser)?.as_tag()?; + let style = get_tag_attr(get_first_child(gd1, parser)?, "style")?; + let cover_regex = regex!(r#"[^(]+\(([^)]+)"#); + let caps = cover_regex.captures(style)?; + gd.galleryInfo.thumbKey = get_thumb_key(&caps[1]); + + let title = dom.get_element_by_id("gn")?.get(parser)?.inner_text(parser); + gd.galleryInfo.title = unescape(&title).ok()?.to_string(); + let title_jpn = dom.get_element_by_id("gj")?.get(parser)?.inner_text(parser); + gd.galleryInfo.titleJpn = unescape(&title_jpn).ok().map(|s| s.to_string()); + + let category = dom + .get_element_by_id("gdc")? + .get(parser)? + .inner_text(parser); + gd.galleryInfo.category = to_category_i32(&category.to_lowercase()); + + gd.galleryInfo.uploader = dom.get_element_by_id("gdn").and_then(|n| { + let gdn = n.get(parser)?.as_tag()?; + gd.galleryInfo.disowned = + get_tag_attr(gdn, "style").is_some_and(|s| s.contains("opacity:0.5")); + let raw = get_first_child(gdn, parser) + .unwrap_or(gdn) + .inner_text(parser); + unescape(&raw).ok().map(|s| s.to_string()) + }); + + dom.get_element_by_id("gdd")? + .get(parser)? + .as_tag()? + .query_selector(parser, "tr")? + .for_each(|node| { + parse_detail_info(gd, &node, parser); + }); + + gd.ratingCount = dom + .get_element_by_id("rating_count")? + .get(parser)? + .inner_text(parser) + .parse() + .ok()?; + + let fav = dom.get_element_by_id("fav")?.get(parser)?.as_tag()?; + gd.galleryInfo.favoriteSlot = get_first_child(fav, parser) + .and_then(|div| { + gd.galleryInfo.favoriteName = get_tag_attr(div, "title") + .and_then(|s| unescape(s).ok()) + .map(|s| s.to_string()); + let fav_slot_regex = regex!(r#"\D+\d+\D+(\d+)"#); + fav_slot_regex + .captures(get_tag_attr(div, "style")?) + .map(|caps| (caps[1].parse().unwrap_or(2) - 2) / 19) + }) + .unwrap_or(-2); + + gd.newerVersions = dom + .get_element_by_id("gnd") + .and_then(|n| n.get(parser)) + .map(|gnd_node| { + let newer_versions_regex = regex!(r#"([^<]+), added ([^<]+)<"#); + newer_versions_regex + .captures_iter(&gnd_node.inner_html(parser)) + .filter_map(|caps| { + let (gid, token) = parse_token_and_gid(&caps[1])?; + Some(BaseGalleryInfo { + gid, + token, + title: unescape(&caps[2]).ok()?.to_string(), + posted: caps[3].to_string(), + ..Default::default() + }) + }) + .collect() + }) + .unwrap_or_default(); + + Some(()) +} + +pub fn parse_gallery_detail(dom: &mut VDom, body: &str) -> Result { + let parser = dom.parser(); + let error = get_vdom_first_element_by_class_name(dom, "d"); + if let Some(tag) = error.and_then(|n| get_first_child(n.as_tag()?, parser)) { + let node = tag.children().top()[0].get(parser).unwrap(); + bail!(EhError::Error(node.inner_text(parser).to_string())); + } + + let mut gallery_detail = GalleryDetail { + tagGroups: parse_tag_groups(dom, parser).context("Failed to parse tag groups")?, + previewList: parse_preview_list(dom, parser).context("Failed to parse preview list")?, + comments: parse_comments(dom).context("Failed to parse comments")?, + ..Default::default() + }; + + parse_detail(&mut gallery_detail, dom, dom.parser(), body) + .context("Failed to parse gallery detail")?; + + Ok(gallery_detail) +} + +pub fn parse_event_pane(dom: &VDom, parser: &Parser) -> Option { + dom.get_element_by_id("eventpane")? + .get(parser) + .map(|n| n.inner_html(parser).to_string()) +} + +fn parse_tag_groups(dom: &VDom, parser: &Parser) -> Option> { + dom.get_element_by_id("taglist")? + .get(parser)? + .as_tag()? + .query_selector(parser, "tr")? + .filter_map(|n| n.get(parser)?.children()) + .map(|children| { + let mut iter = children + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()); + + let namespace = iter + .next()? + .inner_text(parser) + .strip_suffix(':')? + .to_string(); + let tags = iter + .next()? + .children() + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()) + .map(|tag| { + let text = unescape(&tag.inner_text(parser)) + .ok()? + .split('|') + .next()? + .trim() + .to_string(); + let power = match tag.attributes().class()?.try_as_utf8_str()? { + "gtw" => PowerStatus::Weak, + "gtl" => PowerStatus::Active, + _ => PowerStatus::Solid, + }; + let vote = match get_first_child(tag, parser)? + .attributes() + .class()? + .try_as_utf8_str()? + { + "tup" => VoteStatus::Up, + "tdn" => VoteStatus::Down, + _ => VoteStatus::None, + }; + Some(GalleryTag { text, power, vote }) + }) + .collect::>>()?; + + Some(GalleryTagGroup { namespace, tags }) + }) + .collect::>>() +} + +fn parse_comment_time(str: &str, prefix: &str) -> Option { + str.strip_prefix(prefix) + .and_then(|s| NaiveDateTime::parse_and_remainder(s, "%d %B %Y, %R").ok()) + .map(|(dt, _)| dt.and_utc().timestamp_millis()) +} + +fn parse_comments(dom: &mut VDom) -> Result { + let cdiv = dom + .get_element_by_id("cdiv") + .context("Failed to find cdiv")?; + let parser = dom.parser_mut(); + let comment_nodes = cdiv + .get(parser) + .and_then(|n| n.as_tag()?.query_selector(parser, ".c1")) + .unwrap() + .collect::>(); + let comments = comment_nodes + .iter() + .map(|node_handle| { + let node = node_handle.get(parser).unwrap(); + + let c3 = get_first_element_by_class_name(node, parser, "c3") + .and_then(Node::as_tag) + .context("Failed to find c3")?; + let posted_node = c3.children().top()[0].get(parser).unwrap(); + let posted = parse_comment_time(&posted_node.inner_text(parser), "Posted on ") + .context("Failed to parse posted date")?; + let user = get_first_child(c3, parser) + .map(|n| unescape(&n.inner_text(parser)).map(|s| s.to_string())) + .transpose()?; + + let mut editable = false; + let mut vote_up_able = false; + let mut vote_up_ed = false; + let mut vote_down_able = false; + let mut vote_down_ed = false; + if let Some(c4) = + get_first_element_by_class_name(node, parser, "c4").and_then(Node::as_tag) + { + c4.children() + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()) + .for_each(|tag| match tag.inner_text(parser).as_ref() { + "Vote+" => { + vote_up_able = true; + vote_up_ed = tag.attributes().contains("style"); + } + "Vote-" => { + vote_down_able = true; + vote_down_ed = tag.attributes().contains("style"); + } + "Edit" => editable = true, + _ => {} + }) + }; + + let score = get_first_element_by_class_name(node, parser, "c5") + .and_then(|c5| { + get_first_child(c5.as_tag()?, parser)? + .inner_text(parser) + .parse() + .ok() + }) + .unwrap_or(0); + + let vote_state = get_first_element_by_class_name(node, parser, "c7") + .map(|c7| unescape(&c7.inner_text(parser)).map(|s| s.to_string())) + .transpose()?; + + let last_edited = get_first_element_by_class_name(node, parser, "c8") + .and_then(|c8| parse_comment_time(&c8.inner_text(parser), "Last edited on ")) + .unwrap_or(0); + + let c6_handle = node + .find_node(parser, &mut |n| { + n.as_tag() + .is_some_and(|tag| tag.attributes().is_class_member("c6")) + }) + .context("Failed to find c6")?; + let c6 = c6_handle.get(parser).and_then(Node::as_tag).unwrap(); + let nodes = c6 + // Should be "span[style='text-decoration:underline;']" but it's bugged in tl + // https://github.com/y21/tl/issues/46 + .query_selector(parser, "span[style^='text-decoration:underline']") + .unwrap() + .collect::>(); + for node in nodes { + let tag = node.get_mut(parser).and_then(Node::as_tag_mut).unwrap(); + *tag.name_mut() = "u".into(); + tag.attributes_mut().remove("style"); + } + let c6 = c6_handle.get(parser).and_then(Node::as_tag).unwrap(); + let id = c6 + .attributes() + .id() + .and_then(|id| id.try_as_utf8_str()?.strip_prefix("comment_")?.parse().ok()) + .context("Failed to parse comment id")?; + let comment = c6.inner_html(parser).to_string(); + + Ok(GalleryComment { + id, + score, + editable, + voteUpAble: vote_up_able, + voteUpEd: vote_up_ed, + voteDownAble: vote_down_able, + voteDownEd: vote_down_ed, + uploader: id == 0, + voteState: vote_state, + time: posted, + user, + comment, + lastEdited: last_edited, + }) + }) + .collect::>>()?; + + let parser = dom.parser(); + let has_more = dom + .get_element_by_id("chd") + .and_then(|n| { + let tag = n + .get(parser)? + .children()? + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()) + .next()?; + tag.attributes().id() + }) + .is_some_and(|id| id.ne("postnewcomment")); + + Ok(GalleryCommentList { + comments, + hasMore: has_more, + }) +} + +fn parse_preview_list(dom: &VDom, parser: &Parser) -> Option> { + dom.get_element_by_id("gdt")? + .get(parser)? + .children()? + .top() + .iter() + .filter_map(|n| n.get(parser)?.as_tag()) + .map(|a| { + let href_regex = regex!(r#"https[^s#"]+(?:s/([0-9a-f]{10})/\d+-|#page)(\d+)"#); + let caps = href_regex.captures(get_tag_attr(a, "href")?)?; + let p_token = caps.get(1).map_or("", |m| m.as_str()).to_string(); + let position = caps[2].parse::().unwrap() - 1; + + let div = a.query_selector(parser, "div[title]")?.next()?; + let style_regex = regex!(r#"\D+(\d+)\D+(\d+)[^(]+\(([^)]+)\)(?: -(\d+))?"#); + let caps = style_regex.captures(get_node_handle_attr(&div, parser, "style")?)?; + let url = caps[3].to_string(); + let preview = if let Some(offset) = caps.get(4) { + GalleryPreview( + "V2".to_string(), + PreviewData { + url, + position, + pToken: p_token, + offsetX: offset.as_str().parse().ok(), + clipWidth: caps[1].parse().ok(), + clipHeight: caps[2].parse().ok(), + }, + ) + } else { + GalleryPreview( + "V1".to_string(), + PreviewData { + url, + position, + pToken: p_token, + offsetX: None, + clipWidth: None, + clipHeight: None, + }, + ) + }; + + Some(preview) + }) + .collect::>>() +} diff --git a/app/src/main/rust/src/parser/list.rs b/app/src/main/rust/src/parser/list.rs index fe9a333e8..e8c4d3ff9 100644 --- a/app/src/main/rust/src/parser/list.rs +++ b/app/src/main/rust/src/parser/list.rs @@ -10,28 +10,28 @@ use std::borrow::Cow; use std::ops::Index; use tl::{Node, Parser, VDom}; -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, Default)] #[allow(non_snake_case)] -struct BaseGalleryInfo { - gid: i64, - token: String, - title: String, - titleJpn: Option, - thumbKey: String, - category: i32, - posted: String, - uploader: Option, - disowned: bool, - rating: f32, - rated: bool, - simpleTags: Vec, - pages: i32, - thumbWidth: i32, - thumbHeight: i32, - simpleLanguage: Option, - favoriteSlot: i32, - favoriteName: Option, - favoriteNote: Option, +pub struct BaseGalleryInfo { + pub gid: i64, + pub token: String, + pub title: String, + pub titleJpn: Option, + pub thumbKey: String, + pub category: i32, + pub posted: String, + pub uploader: Option, + pub disowned: bool, + pub rating: f32, + pub rated: bool, + pub simpleTags: Vec, + pub pages: i32, + pub thumbWidth: i32, + pub thumbHeight: i32, + pub simpleLanguage: Option, + pub favoriteSlot: i32, + pub favoriteName: Option, + pub favoriteNote: Option, } #[derive(Serialize, Debug)] @@ -42,7 +42,7 @@ pub struct GalleryListResult { galleryInfoList: Vec, } -fn to_category_i32(category: &str) -> i32 { +pub fn to_category_i32(category: &str) -> i32 { match category { "misc" => 0x1, "doujinshi" => 0x2, @@ -75,14 +75,14 @@ fn parse_rating(str: &str) -> f32 { } } -fn get_thumb_key(url: &str) -> String { +pub fn get_thumb_key(url: &str) -> String { url.trim_start_matches(EHGT_PREFIX) .trim_start_matches(EX_PREFIX) .trim_start_matches("t/") .to_string() } -fn parse_token_and_gid(str: &str) -> Option<(i64, String)> { +pub fn parse_token_and_gid(str: &str) -> Option<(i64, String)> { let grp = regex!("https?://(?:exhentai.org|e-hentai.org(?:/lofi)?)/(?:g|mpv)/(\\d+)/([0-9a-f]{10})") .captures(str)?; diff --git a/app/src/main/rust/src/parser/test_parser.rs b/app/src/main/rust/src/parser/test_parser.rs index fb59da048..8d19eef08 100644 --- a/app/src/main/rust/src/parser/test_parser.rs +++ b/app/src/main/rust/src/parser/test_parser.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use crate::parser::archive::{parse_archive_url, parse_archives_with_funds}; + use crate::parser::detail::parse_gallery_detail; use crate::parser::list::parse_info_list; use crate::parser::profile::{parse_profile, parse_profile_url}; use reqwest::get; @@ -52,4 +53,16 @@ mod tests { Some("https://forums.e-hentai.org/ehgt/jdk_180.png".to_string()) ); } + + #[tokio::test] + async fn test_parse_gallery_detail() { + let resp = get("https://e-hentai.org/g/530350/8b3c7e4a21/") + .await + .expect("Failed to get!"); + let body = resp.text().await.expect("Failed to receive!"); + let mut dom = + tl::parse(&body, ParserOptions::default().track_ids()).expect("Failed to parse HTML"); + let result = parse_gallery_detail(&mut dom, &body).expect("Failed to parse gallery detail"); + dbg!(result); + } }