mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
web: support self-hosted Jina Reader
This commit is contained in:
@@ -177,6 +177,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("Jina API Key")
|
||||
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格式返回")
|
||||
val searXngUrl: String by value("")
|
||||
|
||||
|
||||
@@ -4,10 +4,17 @@ import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class VisitWeb : BaseAgent(
|
||||
tool = Tool.function(
|
||||
@@ -36,8 +43,69 @@ class VisitWeb : BaseAgent(
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
// Visit Tool (Using Jina Reader)
|
||||
const val JINA_READER_URL_PREFIX = "https://r.jina.ai/"
|
||||
internal data class ReaderRequest(
|
||||
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
|
||||
@@ -61,12 +129,26 @@ class VisitWeb : BaseAgent(
|
||||
|
||||
private suspend fun jinaReadPage(url: String): String {
|
||||
return try {
|
||||
httpClient.get(JINA_READER_URL_PREFIX + url) {
|
||||
if (PluginConfig.jinaApiKey.isNotEmpty()) {
|
||||
header("Authorization", "Bearer ${PluginConfig.jinaApiKey}")
|
||||
}
|
||||
}.bodyAsText()
|
||||
} catch (e: Throwable) {
|
||||
val request = withContext(Dispatchers.IO) {
|
||||
createReaderRequest(
|
||||
rawUrl = url,
|
||||
readerBaseUrl = PluginConfig.jinaReaderUrl,
|
||||
apiKey = PluginConfig.jinaApiKey,
|
||||
)
|
||||
}
|
||||
val response = httpClient.get(request.endpoint) {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user