Skip to content

Commit

Permalink
SelectorMediaSource: 支持多标签匹配条目
Browse files Browse the repository at this point in the history
  • Loading branch information
Him188 committed Sep 24, 2024
1 parent 2a41d69 commit 6936080
Show file tree
Hide file tree
Showing 14 changed files with 386 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class SelectorMediaSource(
}
}
.mapNotNull { subject ->
doHttpGet(subject.subjectDetailsPageUrl)
doHttpGet(subject.fullUrl)
.getOrNull()
}
.asSequence()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatConfi
import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatIndexed
import me.him188.ani.app.data.source.media.source.web.format.parseOrNull
import org.intellij.lang.annotations.Language

Expand All @@ -33,6 +34,7 @@ data class SelectorSearchConfig(
// Phase 2, for search result, select subjects
val subjectFormatId: SelectorFormatId = SelectorSubjectFormatA.id,
val selectorSubjectFormatA: SelectorSubjectFormatA.Config = SelectorSubjectFormatA.Config(),
val selectorSubjectFormatIndexed: SelectorSubjectFormatIndexed.Config = SelectorSubjectFormatIndexed.Config(),
// Phase 3, for each subject, select channels
val channelFormatId: SelectorFormatId = SelectorChannelFormatNoChannel.id,
val selectorChannelFormatFlattened: SelectorChannelFormatFlattened.Config = SelectorChannelFormatFlattened.Config(),
Expand Down Expand Up @@ -128,6 +130,7 @@ fun <C : SelectorFormatConfig> SelectorSearchConfig.getFormatConfig(format: Sele
@Suppress("UNCHECKED_CAST")
return when (format) {
SelectorSubjectFormatA -> selectorSubjectFormatA as C
SelectorSubjectFormatIndexed -> selectorSubjectFormatIndexed as C
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import me.him188.ani.utils.xml.Element
data class WebSearchSubjectInfo(
val internalId: String,
val name: String,
val subjectDetailsPageUrl: String,
val fullUrl: String,
val partialUrl: String,
val origin: Element?,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
package me.him188.ani.app.data.source.media.source.web.format

import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastMapNotNull
import kotlinx.serialization.Serializable
import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo
import me.him188.ani.utils.xml.Element
import me.him188.ani.utils.xml.QueryParser
import me.him188.ani.utils.xml.parseSelectorOrNull
import org.intellij.lang.annotations.Language
import kotlin.contracts.contract

/**
* 决定如何匹配条目
Expand All @@ -34,7 +36,10 @@ sealed class SelectorSubjectFormat<in Config : SelectorFormatConfig>(override va

companion object {
val entries by lazy { // 必须 lazy, 否则可能获取到 null
listOf(checkNotNull(SelectorSubjectFormatA)) // checkNotNull is needed to be fail-fast
listOf(
checkNotNull(SelectorSubjectFormatA),
checkNotNull(SelectorSubjectFormatIndexed),
) // checkNotNull is needed to be fail-fast
}

fun findById(id: SelectorFormatId): SelectorSubjectFormat<*>? {
Expand Down Expand Up @@ -74,7 +79,8 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat<SelectorSubjectFormat
WebSearchSubjectInfo(
internalId = id,
name = name,
subjectDetailsPageUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href),
fullUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href),
partialUrl = href,
origin = a,
)
}.apply {
Expand All @@ -86,3 +92,76 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat<SelectorSubjectFormat
}
}
}


/**
* 一个语句 select 出所有的名字, 然后一个语句 select 所有的按钮 `<a>`, 按顺序对应
*/
data object SelectorSubjectFormatIndexed :
SelectorSubjectFormat<SelectorSubjectFormatIndexed.Config>(SelectorFormatId("indexed")) {
@Immutable
@Serializable
data class Config(
@Language("css")
val selectNames: String = ".search-box .thumb-content > .thumb-txt",
@Language("css")
val selectLinks: String = ".search-box .thumb-menu > a",
val preferShorterName: Boolean = true,
) : SelectorFormatConfig {
override fun isValid(): Boolean {
return selectNames.isNotBlank()
}
}

override fun select(
document: Element,
baseUrl: String,
config: Config,
): List<WebSearchSubjectInfo>? {
val selectNames = QueryParser.parseSelectorOrNull(config.selectNames) ?: return null
val selectLinks = QueryParser.parseSelectorOrNull(config.selectLinks) ?: return null


val names: List<String> = document.select(selectNames).fastMapNotNull { a ->
a.text().takeIf { it.isNotBlank() }
}

val links = document.select(selectLinks).fastMapNotNull { a ->
val href = a.attr("href")
href.takeIf { it.isNotBlank() }
}

return names.fastZipNotNullToMutable(links) { name, href ->
val id = href.substringBeforeLast(".html").substringAfterLast("/")
WebSearchSubjectInfo(
internalId = id,
name = name,
fullUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href),
partialUrl = href,
origin = null,
)
}.apply {
if (config.preferShorterName) {
sortBy { info ->
info.name.length
}
}
}
}
}

private inline fun <T, R, V : Any> List<T>.fastZipNotNullToMutable(
other: List<R>,
transform: (a: T, b: R) -> V?
): MutableList<V> {
contract { callsInPlace(transform) }
val minSize = minOf(size, other.size)
val target = ArrayList<V>(minSize)
for (i in 0 until minSize) {
val res = transform(get(i), other[i])
if (res != null) {
target += res
}
}
return target
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,22 @@ fun rememberTestSelectorConfigurationState(
@Preview
private fun PreviewSelectorChannelConfigurationColumnNotFound() = ProvideFoundationCompositionLocalsForPreview {
Surface {
SelectorChannelConfigurationColumn(SelectorFormatId("dummy"), rememberTestSelectorConfigurationState())
SelectorChannelFormatColumn(SelectorFormatId("dummy"), rememberTestSelectorConfigurationState())
}
}

@Composable
@Preview
private fun PreviewSelectorChannelConfigurationColumnFlattened() = ProvideFoundationCompositionLocalsForPreview {
Surface {
SelectorChannelConfigurationColumn(SelectorChannelFormatFlattened.id, rememberTestSelectorConfigurationState())
SelectorChannelFormatColumn(SelectorChannelFormatFlattened.id, rememberTestSelectorConfigurationState())
}
}

@Composable
@Preview
private fun PreviewSelectorChannelConfigurationColumnNoChannel() = ProvideFoundationCompositionLocalsForPreview {
Surface {
SelectorChannelConfigurationColumn(SelectorChannelFormatNoChannel.id, rememberTestSelectorConfigurationState())
SelectorChannelFormatColumn(SelectorChannelFormatNoChannel.id, rememberTestSelectorConfigurationState())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ class TestSelectorMediaSourceEngine : SelectorMediaSourceEngine() {

override fun selectSubjects(document: Element, config: SelectorSearchConfig): List<WebSearchSubjectInfo> {
return listOf(
WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com/1.html", "1.html", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com/2.html", "2.html", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com/3.html", "3.html", null),
WebSearchSubjectInfo("a", "Test Subject", "https://example.com/4.html", "4.html", null),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor
import me.him188.ani.app.ui.settings.mediasource.MediaSourceConfigurationDefaults

@Composable
internal fun SelectorChannelConfigurationColumn(
internal fun SelectorChannelFormatColumn(
formatId: SelectorFormatId,
state: SelectorConfigState,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -105,23 +105,26 @@ internal fun SelectorChannelConfigurationColumn(
}

null -> {
Column(
Modifier.align(Alignment.CenterHorizontally),
) {
ProvideTextStyleContentColor(MaterialTheme.typography.bodyLarge, MaterialTheme.colorScheme.error) {
Icon(
Icons.Rounded.Error, null,
Modifier.align(Alignment.CenterHorizontally).size(48.dp),
)
Text(
"当前版本不支持该配置类型:${formatId.value}\n\n这可能是导入了一个在更高版本编辑的配置导致的\n可升级 Ani 或切换到其他配置类型",
Modifier.padding(top = 24.dp),
textAlign = TextAlign.Center,
)
}
}
UnsupportedFormatIdHint(formatId, Modifier.align(Alignment.CenterHorizontally))
}
}
}
}

@Composable
internal fun UnsupportedFormatIdHint(formatId: SelectorFormatId, modifier: Modifier = Modifier) {
Column(modifier) {
ProvideTextStyleContentColor(MaterialTheme.typography.bodyLarge, MaterialTheme.colorScheme.error) {
Icon(
Icons.Rounded.Error, null,
Modifier.align(Alignment.CenterHorizontally).size(48.dp),
)
Text(
"当前版本不支持该配置类型:${formatId.value}\n\n这可能是导入了一个在更高版本编辑的配置导致的\n可升级 Ani 或切换到其他配置类型",
Modifier.padding(top = 24.dp),
textAlign = TextAlign.Center,
)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import androidx.compose.ui.unit.dp
import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened
import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel
import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA
import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatIndexed
import me.him188.ani.app.ui.foundation.animation.StandardEasing
import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter
import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor
Expand Down Expand Up @@ -141,27 +144,25 @@ internal fun SelectorConfigurationPane(
},
colors = listItemColors,
)
}

val conf = state.subjectFormatA
OutlinedTextField(
conf.selectLists, { conf.selectLists = it },
Modifier.fillMaxWidth().moveFocusOnEnter().padding(top = verticalSpacing),
label = { Text("提取条目列表") },
supportingText = { Text("CSS Selector 表达式。期望返回一些 <a>,每个对应一个条目,将会读取其 href 属性和 text") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
shape = textFieldShape,
isError = conf.selectListsIsError,
)
ListItem(
headlineContent = { Text("选择最短标题") },
Modifier
.padding(top = (verticalSpacing - 8.dp).coerceAtLeast(0.dp))
.clickable { conf.preferShorterName = !conf.preferShorterName },
supportingContent = { Text("选择满足匹配的标题最短的条目。可避免为第一季匹配到第二季") },
trailingContent = {
Switch(conf.preferShorterName, { conf.preferShorterName = it })
},
colors = listItemColors,
SelectorSubjectFormatSelectionButtonRow(
state,
Modifier.fillMaxWidth().padding(bottom = 4.dp),
)

AnimatedContent(
SelectorSubjectFormat.findById(state.subjectFormatId),
Modifier
.padding(vertical = 16.dp)
.fillMaxWidth()
.animateContentSize(tween(EasingDurations.standard, easing = StandardEasing)),
transitionSpec = AniThemeDefaults.standardAnimatedContentTransition,
) { format ->
SelectorSubjectConfigurationColumn(
format, state,
textFieldShape, verticalSpacing, listItemColors,
Modifier.fillMaxWidth(),
)
}

Expand All @@ -187,7 +188,7 @@ internal fun SelectorConfigurationPane(
.animateContentSize(tween(EasingDurations.standard, easing = StandardEasing)),
transitionSpec = AniThemeDefaults.standardAnimatedContentTransition,
) { formatId ->
SelectorChannelConfigurationColumn(formatId, state, Modifier.fillMaxWidth())
SelectorChannelFormatColumn(formatId, state, Modifier.fillMaxWidth())
}

Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) {
Expand Down Expand Up @@ -276,6 +277,43 @@ internal fun SelectorConfigurationPane(
}
}


@Composable
private fun SelectorSubjectFormatSelectionButtonRow(
state: SelectorConfigState,
modifier: Modifier = Modifier,
) {
SingleChoiceSegmentedButtonRow(modifier) {
@Composable
fun Btn(
id: SelectorFormatId, index: Int,
enabled: Boolean = true,
label: @Composable () -> Unit,
) {
SegmentedButton(
state.subjectFormatId == id,
{ state.subjectFormatId = id },
SegmentedButtonDefaults.itemShape(index, state.allSubjectFormats.size),
icon = { SegmentedButtonDefaults.Icon(state.subjectFormatId == id) },
label = label,
enabled = enabled,
)
}

for ((index, format) in state.allSubjectFormats.withIndex()) {
Btn(format.id, index) {
Text(
when (format) { // type-safe to handle all formats
SelectorSubjectFormatA -> "单标签"
SelectorSubjectFormatIndexed -> "多标签"
},
softWrap = false,
)
}
}
}
}

@Composable
private fun SelectorChannelSelectionButtonRow(
state: SelectorConfigState,
Expand Down
Loading

0 comments on commit 6936080

Please sign in to comment.