[Search] Reorganize skill output and detect recaptchas

This commit is contained in:
Stypox 2025-10-09 00:34:00 +02:00
parent e2c5dc9808
commit c16625f7f3
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
3 changed files with 97 additions and 66 deletions

View File

@ -21,65 +21,21 @@ import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.InteractionPlan
import org.dicio.skill.skill.SkillOutput
import org.stypox.dicio.R
import org.stypox.dicio.io.graphical.Headline
import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput
import org.stypox.dicio.sentences.Sentences
import org.stypox.dicio.skills.search.SearchOutput.Data
import org.stypox.dicio.util.RecognizeEverythingSkill
import org.stypox.dicio.util.ShareUtils
import org.stypox.dicio.util.getString
class SearchOutput(
private val results: List<Data>?,
private val askAgain: Boolean,
) : SkillOutput {
class Data (
val title: String,
val thumbnailUrl: String,
val url: String,
val description: String,
)
sealed interface SearchOutput : SkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString(
if (results == null)
R.string.skill_search_what_question
else if (results.isNotEmpty())
R.string.skill_search_here_is_what_i_found
else if (askAgain)
R.string.skill_search_no_results
else
// if the search continues to return 0 results, don't keep asking
R.string.skill_search_no_results_stop
)
data class Results(private val results: List<Data>) : SearchOutput {
override fun getSpeechOutput(ctx: SkillContext): String =
ctx.getString(R.string.skill_search_here_is_what_i_found)
override fun getInteractionPlan(ctx: SkillContext): InteractionPlan {
if (!results.isNullOrEmpty() || !askAgain) {
return InteractionPlan.FinishInteraction
}
val searchAnythingSkill = object : RecognizeEverythingSkill(SearchInfo) {
override suspend fun generateOutput(
ctx: SkillContext,
inputData: String
): SkillOutput {
// ask again only if this is the first time we ask the user to provide what
// to search for, otherwise we could continue asking indefinitely
return SearchOutput(searchOnDuckDuckGo(ctx, inputData), results == null)
}
}
return InteractionPlan.StartSubInteraction(
reopenMicrophone = true,
nextSkills = listOf(
SearchSkill(SearchInfo, Sentences.Search[ctx.sentencesLanguage]!!),
searchAnythingSkill,
),
)
}
@Composable
override fun GraphicalOutput(ctx: SkillContext) {
if (results.isNullOrEmpty()) {
Headline(text = getSpeechOutput(ctx))
} else {
@Composable
override fun GraphicalOutput(ctx: SkillContext) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@ -89,15 +45,70 @@ class SearchOutput(
}
}
}
object NoSearchTerm : SearchOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String =
ctx.getString(R.string.skill_search_what_question)
override fun getInteractionPlan(ctx: SkillContext): InteractionPlan =
getRetryInteractionPlan(ctx)
}
object NoResultAskAgain : SearchOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String =
ctx.getString(R.string.skill_search_no_results)
override fun getInteractionPlan(ctx: SkillContext): InteractionPlan =
getRetryInteractionPlan(ctx)
}
object NoResultStop : SearchOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String =
ctx.getString(R.string.skill_search_no_results_stop)
}
object RecaptchaRequested : SearchOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String =
ctx.getString(R.string.skill_search_duckduckgo_recaptcha)
}
class Data (
val title: String,
val thumbnailUrl: String,
val url: String,
val description: String,
)
companion object {
private fun getRetryInteractionPlan(ctx: SkillContext): InteractionPlan {
val searchAnythingSkill = object : RecognizeEverythingSkill(SearchInfo) {
override suspend fun generateOutput(
ctx: SkillContext,
inputData: String
): SkillOutput {
// ask again only if this is the first time we ask the user to provide what
// to search for, otherwise we could continue asking indefinitely
return searchOnDuckDuckGo(ctx, inputData, askAgainIfNoResult = false)
}
}
return InteractionPlan.StartSubInteraction(
reopenMicrophone = true,
nextSkills = listOf(
SearchSkill(SearchInfo, Sentences.Search[ctx.sentencesLanguage]!!),
searchAnythingSkill,
),
)
}
}
}
@Composable
private fun SearchResult(data: SearchOutput.Data) {
private fun SearchResult(data: Data) {
val context = LocalContext.current
Column(
modifier = Modifier.clickable {
ShareUtils.openUrlInBrowser(context, data.url)
}
modifier = Modifier.clickable { ShareUtils.openUrlInBrowser(context, data.url) }
) {
Row(
verticalAlignment = Alignment.CenterVertically

View File

@ -15,10 +15,8 @@ import org.stypox.dicio.util.LocaleUtils
class SearchSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData<Search>)
: StandardRecognizerSkill<Search>(correspondingSkillInfo, data) {
override suspend fun generateOutput(ctx: SkillContext, inputData: Search): SkillOutput {
val query = when (inputData) {
is Search.Query -> inputData.what ?: return SearchOutput(null, true)
}
return SearchOutput(searchOnDuckDuckGo(ctx, query), true)
val query = when (inputData) { is Search.Query -> inputData.what }
return searchOnDuckDuckGo(ctx, query, true)
}
}
@ -31,17 +29,25 @@ private val DUCK_DUCK_GO_SUPPORTED_LOCALES = listOf(
"it-it", "jp-jp", "kr-kr", "lv-lv", "lt-lt", "my-en", "mx-es", "nl-nl", "nz-en",
"no-no", "pk-en", "pe-es", "ph-en", "pl-pl", "pt-pt", "ro-ro", "ru-ru", "xa-ar",
"sg-en", "sk-sk", "sl-sl", "za-en", "es-ca", "es-es", "se-sv", "ch-de", "ch-fr",
"tw-tz", "th-en", "tr-tr", /*"us-en",*/ "us-es", "ua-uk", "uk-en", "vn-en"
"tw-tz", "th-en", "tr-tr", "us-en", "us-es", "ua-uk", "uk-en", "vn-en"
)
internal fun searchOnDuckDuckGo(ctx: SkillContext, query: String): List<SearchOutput.Data> {
internal fun searchOnDuckDuckGo(
ctx: SkillContext,
query: String?,
askAgainIfNoResult: Boolean,
): SearchOutput {
if (query.isNullOrBlank()) {
return SearchOutput.NoSearchTerm
}
// find the locale supported by DuckDuckGo that matches the user locale the most
val locale = LocaleUtils.resolveValueForSupportedLocale(
ctx.locale,
DUCK_DUCK_GO_SUPPORTED_LOCALES.associateBy {
// DuckDuckGo locale names have first the country and then the language, but the locale
// selection function assumes the opposite
it.split("-").reversed().joinToString(separator = "_")
it.split("-").reversed().joinToString(separator = "-")
}
// default to English when no locale is supported
) ?: "us-en"
@ -57,16 +63,21 @@ internal fun searchOnDuckDuckGo(ctx: SkillContext, query: String): List<SearchOu
)
)
// Sometimes DuckDuckGo replies with a recaptcha request, and no results are provided then
if (html.contains("anomaly-modal__title")) {
return SearchOutput.RecaptchaRequested
}
val document: Document = Jsoup.parse(html)
val elements = document.select("div[class=links_main links_deep result__body]")
val result: MutableList<SearchOutput.Data> = ArrayList()
val results: MutableList<SearchOutput.Data> = ArrayList()
for (element in elements) {
try {
// the url is under the "uddg" query parameter
val ddgUrl = element.select("a[class=result__a]").first()!!.attr("href")
val url = ddgUrl.toUri().getQueryParameter("uddg")!!
result.add(
results.add(
SearchOutput.Data(
title = element.select("a[class=result__a]").first()!!.text(),
thumbnailUrl = "https:" + element.select("img[class=result__icon__img]")
@ -79,5 +90,13 @@ internal fun searchOnDuckDuckGo(ctx: SkillContext, query: String): List<SearchOu
}
}
return result
return if (results.isEmpty()) {
if (askAgainIfNoResult) {
SearchOutput.NoResultAskAgain
} else {
SearchOutput.NoResultStop
}
} else {
SearchOutput.Results(results)
}
}

View File

@ -245,4 +245,5 @@
<string name="skill_weather_miles_per_hour">mph</string>
<string name="failed_to_copy">Failed to copy to clipboard</string>
<string name="skill_translation_auto">Auto</string>
<string name="skill_search_duckduckgo_recaptcha">DuckDuckGo did not provide results, asking for a Captcha to be solved</string>
</resources>