web: support self-hosted Jina Reader

This commit is contained in:
2026-08-04 23:59:28 +08:00
parent 7a0969dc4e
commit 1c47a69716
4 changed files with 175 additions and 8 deletions
+2
View File
@@ -161,6 +161,8 @@ imageWatermark: false
ttsModel: 'qwen3-tts-instruct-flash' ttsModel: 'qwen3-tts-instruct-flash'
# Jina API Key # Jina API Key
jinaApiKey: '' jinaApiKey: ''
# Jina Reader API 地址;自托管 Docker 服务示例:http://127.0.0.1:4223/
jinaReaderUrl: 'https://r.jina.ai/'
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回 # SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
searXngUrl: '' searXngUrl: ''
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。 # 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
+3
View File
@@ -177,6 +177,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("Jina API Key") @ValueDescription("Jina API Key")
val jinaApiKey by value("") val jinaApiKey by value("")
@ValueDescription("Jina Reader API 地址,默认使用在线服务;自托管示例:http://127.0.0.1:4223/")
val jinaReaderUrl: String by value("https://r.jina.ai/")
@ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回") @ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回")
val searXngUrl: String by value("") val searXngUrl: String by value("")
+89 -7
View File
@@ -4,10 +4,17 @@ import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters import com.aallam.openai.api.core.Parameters
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import top.jie65535.mirai.config.PluginConfig import top.jie65535.mirai.config.PluginConfig
import java.net.InetAddress
import java.net.URI
import java.net.UnknownHostException
class VisitWeb : BaseAgent( class VisitWeb : BaseAgent(
tool = Tool.function( tool = Tool.function(
@@ -36,8 +43,69 @@ class VisitWeb : BaseAgent(
) )
) { ) {
companion object { companion object {
// Visit Tool (Using Jina Reader) internal data class ReaderRequest(
const val JINA_READER_URL_PREFIX = "https://r.jina.ai/" val endpoint: String,
val apiKey: String?,
)
internal fun createReaderRequest(
rawUrl: String,
readerBaseUrl: String,
apiKey: String,
resolveAddresses: (String) -> List<InetAddress> = {
InetAddress.getAllByName(it).toList()
},
): ReaderRequest {
val target = parseHttpUrl(rawUrl, "网页地址")
require(target.userInfo == null) { "网页地址不能包含用户凭据" }
val targetHost = requireNotNull(target.host).removeSurrounding("[", "]").lowercase()
require(targetHost.contains('.') || targetHost.contains(':')) {
"禁止访问单标签或内部主机名"
}
require(
targetHost != "localhost" &&
!targetHost.endsWith(".localhost") &&
!targetHost.endsWith(".local") &&
!targetHost.endsWith(".internal") &&
!targetHost.endsWith(".lan") &&
!targetHost.endsWith(".home.arpa")
) { "禁止访问本机或局域网网页地址" }
val addresses = try {
resolveAddresses(targetHost)
} catch (_: UnknownHostException) {
// 目标可能只能由 Mihomo 的 DNS 解析,交给代理继续处理。
emptyList()
}
require(addresses.all(VisualImageResolver::isPublicAddress)) {
"网页地址解析到非公网地址,已拒绝访问"
}
val readerBase = parseHttpUrl(readerBaseUrl, "Jina Reader API 地址")
require(readerBase.query == null && readerBase.fragment == null) {
"Jina Reader API 地址不能包含查询参数或片段"
}
return ReaderRequest(
endpoint = readerBaseUrl.trim().trimEnd('/') + "/" +
target.toASCIIString().substringBefore('#'),
apiKey = apiKey.trim().takeIf(String::isNotEmpty),
)
}
private fun parseHttpUrl(rawUrl: String, label: String): URI {
val uri = try {
URI(rawUrl.trim())
} catch (e: Exception) {
throw IllegalArgumentException("$label 格式无效", e)
}
require(uri.scheme?.lowercase() in setOf("http", "https")) {
"$label 仅支持 HTTP/HTTPS"
}
require(!uri.host.isNullOrBlank()) { "$label 缺少有效主机名" }
return uri.normalize()
}
} }
override val isEnabled: Boolean override val isEnabled: Boolean
@@ -61,12 +129,26 @@ class VisitWeb : BaseAgent(
private suspend fun jinaReadPage(url: String): String { private suspend fun jinaReadPage(url: String): String {
return try { return try {
httpClient.get(JINA_READER_URL_PREFIX + url) { val request = withContext(Dispatchers.IO) {
if (PluginConfig.jinaApiKey.isNotEmpty()) { createReaderRequest(
header("Authorization", "Bearer ${PluginConfig.jinaApiKey}") rawUrl = url,
readerBaseUrl = PluginConfig.jinaReaderUrl,
apiKey = PluginConfig.jinaApiKey,
)
} }
}.bodyAsText() val response = httpClient.get(request.endpoint) {
} catch (e: Throwable) { header(HttpHeaders.Accept, ContentType.Text.Plain)
request.apiKey?.let { header(HttpHeaders.Authorization, "Bearer $it") }
}
val body = response.bodyAsText()
if (response.status.isSuccess()) {
body
} else {
"Error fetching \"$url\": HTTP ${response.status.value} ${body.take(500)}"
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
"Error fetching \"$url\": ${e.message}" "Error fetching \"$url\": ${e.message}"
} }
} }
+80
View File
@@ -0,0 +1,80 @@
package top.jie65535.mirai.tools
import java.net.InetAddress
import java.net.UnknownHostException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class VisitWebTest {
@Test
fun `self hosted reader request uses configured base url`() {
val request = VisitWeb.createReaderRequest(
rawUrl = "https://example.com/docs?q=reader#section",
readerBaseUrl = "http://127.0.0.1:4223/",
apiKey = "",
resolveAddresses = { listOf(InetAddress.getByName("93.184.216.34")) },
)
assertEquals(
"http://127.0.0.1:4223/https://example.com/docs?q=reader",
request.endpoint,
)
assertEquals(null, request.apiKey)
}
@Test
fun `hosted reader remains backward compatible`() {
val request = VisitWeb.createReaderRequest(
rawUrl = "https://example.com/",
readerBaseUrl = "https://r.jina.ai/",
apiKey = "token",
resolveAddresses = { listOf(InetAddress.getByName("93.184.216.34")) },
)
assertEquals("https://r.jina.ai/https://example.com/", request.endpoint)
assertEquals("token", request.apiKey)
}
@Test
fun `private resolved targets are rejected`() {
assertFailsWith<IllegalArgumentException> {
VisitWeb.createReaderRequest(
rawUrl = "https://example.com/",
readerBaseUrl = "http://127.0.0.1:4223/",
apiKey = "",
resolveAddresses = { listOf(InetAddress.getByName("192.168.1.10")) },
)
}
}
@Test
fun `internal hostnames and credentials are rejected`() {
assertFailsWith<IllegalArgumentException> {
VisitWeb.createReaderRequest(
rawUrl = "http://redis/",
readerBaseUrl = "http://127.0.0.1:4223/",
apiKey = "",
)
}
assertFailsWith<IllegalArgumentException> {
VisitWeb.createReaderRequest(
rawUrl = "https://user:[email protected]/",
readerBaseUrl = "http://127.0.0.1:4223/",
apiKey = "",
)
}
}
@Test
fun `unresolved public host may be resolved by reader deployment`() {
val request = VisitWeb.createReaderRequest(
rawUrl = "https://blocked.example/",
readerBaseUrl = "http://127.0.0.1:4223/",
apiKey = "",
resolveAddresses = { throw UnknownHostException("proxy DNS only") },
)
assertEquals("http://127.0.0.1:4223/https://blocked.example/", request.endpoint)
}
}