koishi-plugin-chatluna-image-resolver 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +52 -0
- package/lib/index.d.ts +92 -0
- package/lib/index.js +753 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
5
|
+
Everyone is permitted to copy and distribute verbatim copies
|
|
6
|
+
of this license document, but changing it is not allowed.
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 yabo083
|
|
9
|
+
|
|
10
|
+
This program is free software: you can redistribute it and/or modify
|
|
11
|
+
it under the terms of the GNU Affero General Public License version 3
|
|
12
|
+
as published by the Free Software Foundation.
|
|
13
|
+
|
|
14
|
+
This program is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
17
|
+
GNU Affero General Public License for more details.
|
|
18
|
+
|
|
19
|
+
You should have received a copy of the GNU Affero General Public License
|
|
20
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
21
|
+
|
|
22
|
+
Full license text:
|
|
23
|
+
https://www.gnu.org/licenses/agpl-3.0.txt
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# koishi-plugin-chatluna-image-resolver
|
|
2
|
+
|
|
3
|
+
ChatLuna 图片解析工具插件。
|
|
4
|
+
|
|
5
|
+
当前主路径使用 SerpApi Google Images:把“关键词搜图、获取原图候选、下载外链、转存为 Koishi 可访问 URL”封装成一个工具,减少模型直接处理防盗链、超时外链和大 HTML 的机会。
|
|
6
|
+
|
|
7
|
+
## ChatLuna 工具
|
|
8
|
+
|
|
9
|
+
默认注册工具:`image_search_resolve`
|
|
10
|
+
|
|
11
|
+
输入:
|
|
12
|
+
|
|
13
|
+
- `query`: 图片搜索词。
|
|
14
|
+
- `count`: 返回图片数量,默认 1。
|
|
15
|
+
- `safeMode`: 是否启用保守过滤,默认 true。
|
|
16
|
+
|
|
17
|
+
输出:
|
|
18
|
+
|
|
19
|
+
- `ok`: 是否找到并转存图片。
|
|
20
|
+
- `images`: 已转存图片列表,包含 `url`、`sourcePage`、`originalUrl`、`width`、`height`、`bytes`。
|
|
21
|
+
- `failures`: 被跳过或失败的来源摘要。
|
|
22
|
+
|
|
23
|
+
## 搜索来源
|
|
24
|
+
|
|
25
|
+
默认搜索提供方:`serpapi`
|
|
26
|
+
|
|
27
|
+
SerpApi 会调用 Google Images Search API(`engine=google_images`),优先读取 `images_results[].original` 作为待下载原图,并把 `images_results[].link` 作为下载 Referer。插件下载成功后仍会转存到 ChatLuna Storage 或本地 HTTP 路由,避免直接把第三方图片热链交给聊天平台。
|
|
28
|
+
|
|
29
|
+
推荐配置:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
chatluna-image-resolver:
|
|
33
|
+
search:
|
|
34
|
+
provider: serpapi
|
|
35
|
+
serpApiKey: <your-serpapi-api-key>
|
|
36
|
+
serpApiGoogleDomain: google.com
|
|
37
|
+
serpApiGl: cn
|
|
38
|
+
serpApiHl: zh-cn
|
|
39
|
+
serpApiSafe: active
|
|
40
|
+
delivery:
|
|
41
|
+
publicBaseUrl: http://172.26.0.1:5140
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
如果希望 SerpApi 不足时继续尝试 Tavily/DuckDuckGo 页面解析,可将 `provider` 改为 `serpapi-fallback`。
|
|
45
|
+
|
|
46
|
+
`delivery.publicBaseUrl` 用于控制最终返回给聊天平台的图片 URL 根地址。NapCat/OneBot 在 Docker 中运行时,容器内的 `127.0.0.1` 不是宿主 Koishi;此时应填写容器可访问宿主的地址,例如 Docker 网桥网关 `http://172.26.0.1:5140` 或局域网地址 `http://192.168.0.107:5140`。
|
|
47
|
+
|
|
48
|
+
## 存储
|
|
49
|
+
|
|
50
|
+
优先使用 `chatluna-storage-service` 的 `createTempFile()` 生成本地可访问链接。
|
|
51
|
+
|
|
52
|
+
可选 WebDAV 同步会在本地转存成功后,把同一份图片用 HTTP `PUT` 上传到 WebDAV 目录,并在结果里返回 `webdavUrl`。
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
export declare const name = "chatluna-image-resolver";
|
|
3
|
+
export declare const inject: {
|
|
4
|
+
optional: readonly ["chatluna", "chatluna_storage", "puppeteer", "server"];
|
|
5
|
+
};
|
|
6
|
+
export interface WebDavConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
endpoint: string;
|
|
9
|
+
username: string;
|
|
10
|
+
password: string;
|
|
11
|
+
basePath: string;
|
|
12
|
+
publicBaseUrl: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Config {
|
|
15
|
+
tool: {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
};
|
|
20
|
+
search: {
|
|
21
|
+
provider: 'serpapi' | 'serpapi-fallback' | 'duckduckgo' | 'tavily' | 'both';
|
|
22
|
+
serpApiKey: string;
|
|
23
|
+
serpApiGoogleDomain: string;
|
|
24
|
+
serpApiGl: string;
|
|
25
|
+
serpApiHl: string;
|
|
26
|
+
serpApiSafe: 'active' | 'off';
|
|
27
|
+
tavilyApiKey: string;
|
|
28
|
+
maxSearchResults: number;
|
|
29
|
+
maxPages: number;
|
|
30
|
+
pageTimeoutMs: number;
|
|
31
|
+
usePuppeteerFallback: boolean;
|
|
32
|
+
};
|
|
33
|
+
image: {
|
|
34
|
+
maxCount: number;
|
|
35
|
+
maxDownloadBytes: number;
|
|
36
|
+
minWidth: number;
|
|
37
|
+
minHeight: number;
|
|
38
|
+
tempExpireHours: number;
|
|
39
|
+
userAgent: string;
|
|
40
|
+
};
|
|
41
|
+
storage: {
|
|
42
|
+
localFallback: boolean;
|
|
43
|
+
localDirectory: string;
|
|
44
|
+
localPublicPath: string;
|
|
45
|
+
};
|
|
46
|
+
delivery: {
|
|
47
|
+
publicBaseUrl: string;
|
|
48
|
+
};
|
|
49
|
+
webdav: WebDavConfig;
|
|
50
|
+
debug: boolean;
|
|
51
|
+
}
|
|
52
|
+
interface ImageCandidate {
|
|
53
|
+
url: string;
|
|
54
|
+
sourcePage: string;
|
|
55
|
+
score: number;
|
|
56
|
+
width?: number;
|
|
57
|
+
height?: number;
|
|
58
|
+
reason: string;
|
|
59
|
+
}
|
|
60
|
+
export declare const Config: Schema<Config>;
|
|
61
|
+
export declare const usage = "\n<p><strong>ChatLuna \u56FE\u7247\u89E3\u6790\u5668</strong></p>\n<p>\u6CE8\u518C <code>image_search_resolve</code> \u5DE5\u5177\uFF0C\u7528\u4E8E\u641C\u7D22\u56FE\u7247\u3001\u63D0\u53D6\u5019\u9009\u56FE\u7247\u3001\u4E0B\u8F7D\u5916\u94FE\u3001\u8F6C\u5B58\u4E3A Koishi \u53EF\u8BBF\u95EE\u94FE\u63A5\uFF0C\u5E76\u53EF\u9009\u540C\u6B65\u5230 WebDAV\u3002</p>\n<p>\u5EFA\u8BAE\u8BA9\u89D2\u8272\u9884\u8BBE\u5728\u56FE\u7247\u8BF7\u6C42\u4E2D\u4F18\u5148\u8C03\u7528\u8BE5\u5DE5\u5177\uFF0C\u518D\u628A\u8FD4\u56DE\u7684 <code>images[].url</code> \u653E\u8FDB <code>character_reply.image</code>\u3002</p>\n";
|
|
62
|
+
declare module 'koishi' {
|
|
63
|
+
interface Context {
|
|
64
|
+
chatluna?: any;
|
|
65
|
+
chatluna_storage?: {
|
|
66
|
+
createTempFile: (buffer: Buffer, filename: string, expireHours?: number, mimeType?: string) => Promise<{
|
|
67
|
+
url: string;
|
|
68
|
+
}>;
|
|
69
|
+
};
|
|
70
|
+
puppeteer?: {
|
|
71
|
+
page: () => Promise<any>;
|
|
72
|
+
};
|
|
73
|
+
server?: {
|
|
74
|
+
selfUrl?: string;
|
|
75
|
+
get: (path: string, handler: (koa: any) => Promise<void> | void) => void;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
80
|
+
export interface SerpApiImagesUrlOptions {
|
|
81
|
+
apiKey: string;
|
|
82
|
+
query: string;
|
|
83
|
+
count: number;
|
|
84
|
+
googleDomain?: string;
|
|
85
|
+
gl?: string;
|
|
86
|
+
hl?: string;
|
|
87
|
+
safe?: 'active' | 'off';
|
|
88
|
+
}
|
|
89
|
+
export declare function buildSerpApiImagesUrl(options: SerpApiImagesUrlOptions): string;
|
|
90
|
+
export declare function serpApiImagesToCandidates(payload: any): ImageCandidate[];
|
|
91
|
+
export declare function rewriteUrlBase(url: string, publicBaseUrl: string): string;
|
|
92
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.usage = exports.Config = exports.inject = exports.name = void 0;
|
|
4
|
+
exports.apply = apply;
|
|
5
|
+
exports.buildSerpApiImagesUrl = buildSerpApiImagesUrl;
|
|
6
|
+
exports.serpApiImagesToCandidates = serpApiImagesToCandidates;
|
|
7
|
+
exports.rewriteUrlBase = rewriteUrlBase;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const tools_1 = require("@langchain/core/tools");
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const promises_1 = require("node:fs/promises");
|
|
13
|
+
const node_path_1 = require("node:path");
|
|
14
|
+
exports.name = 'chatluna-image-resolver';
|
|
15
|
+
exports.inject = { optional: ['chatluna', 'chatluna_storage', 'puppeteer', 'server'] };
|
|
16
|
+
const TOOL_SCHEMA = zod_1.z.object({
|
|
17
|
+
query: zod_1.z.string().min(1).describe('Image search query, for example "天童爱丽丝 普通图片" or "Tendou Aris fanart".'),
|
|
18
|
+
count: zod_1.z.number().int().min(1).max(8).optional().describe('Number of images to resolve. Defaults to 1.'),
|
|
19
|
+
safeMode: zod_1.z.boolean().optional().describe('Use conservative filtering for icons, logos, tiny images, and risky pages. Defaults to true.')
|
|
20
|
+
});
|
|
21
|
+
exports.Config = koishi_1.Schema.intersect([
|
|
22
|
+
koishi_1.Schema.object({
|
|
23
|
+
tool: koishi_1.Schema.object({
|
|
24
|
+
enabled: koishi_1.Schema.boolean().default(true).description('是否注册 ChatLuna 工具。'),
|
|
25
|
+
name: koishi_1.Schema.string().default('image_search_resolve').description('ChatLuna 工具名称。'),
|
|
26
|
+
description: koishi_1.Schema.string().role('textarea').default('Searches for images, extracts real image candidates, downloads them with browser-like headers, stores them as Koishi-accessible URLs, and returns ready-to-send image links. Use this instead of sending remote hotlink URLs directly.').description('工具描述。')
|
|
27
|
+
}).description('工具')
|
|
28
|
+
}),
|
|
29
|
+
koishi_1.Schema.object({
|
|
30
|
+
search: koishi_1.Schema.object({
|
|
31
|
+
provider: koishi_1.Schema.union([
|
|
32
|
+
koishi_1.Schema.const('serpapi').description('SerpApi Google Images,直接返回原图候选。'),
|
|
33
|
+
koishi_1.Schema.const('serpapi-fallback').description('优先 SerpApi Google Images,不足时回退到 Tavily/DuckDuckGo 网页解析。'),
|
|
34
|
+
koishi_1.Schema.const('duckduckgo').description('DuckDuckGo HTML/Lite 搜索。'),
|
|
35
|
+
koishi_1.Schema.const('tavily').description('Tavily 搜索。'),
|
|
36
|
+
koishi_1.Schema.const('both').description('先 Tavily 后 DuckDuckGo。')
|
|
37
|
+
]).default('serpapi').description('搜索提供方。'),
|
|
38
|
+
serpApiKey: koishi_1.Schema.string().role('secret').default('').description('SerpApi API Key。provider 为 SerpApi 时必填。'),
|
|
39
|
+
serpApiGoogleDomain: koishi_1.Schema.string().default('google.com').description('SerpApi google_domain;留空则使用默认。'),
|
|
40
|
+
serpApiGl: koishi_1.Schema.string().default('cn').description('SerpApi gl 地区参数。'),
|
|
41
|
+
serpApiHl: koishi_1.Schema.string().default('zh-cn').description('SerpApi hl 语言参数。'),
|
|
42
|
+
serpApiSafe: koishi_1.Schema.union([
|
|
43
|
+
koishi_1.Schema.const('active').description('开启 Google SafeSearch。'),
|
|
44
|
+
koishi_1.Schema.const('off').description('关闭 Google SafeSearch。')
|
|
45
|
+
]).default('active').description('SerpApi safe 参数。'),
|
|
46
|
+
tavilyApiKey: koishi_1.Schema.string().role('secret').default('').description('Tavily API Key,留空则跳过 Tavily。'),
|
|
47
|
+
maxSearchResults: koishi_1.Schema.number().min(1).max(100).default(12).description('最多读取多少条搜索结果。'),
|
|
48
|
+
maxPages: koishi_1.Schema.number().min(1).max(8).default(4).description('最多打开多少个候选页面。'),
|
|
49
|
+
pageTimeoutMs: koishi_1.Schema.number().min(3000).max(60000).default(12000).description('页面抓取/下载超时。'),
|
|
50
|
+
usePuppeteerFallback: koishi_1.Schema.boolean().default(true).description('普通 HTML 抓不到图片时,是否用 Puppeteer 读取 DOM 图片。')
|
|
51
|
+
}).description('搜索')
|
|
52
|
+
}),
|
|
53
|
+
koishi_1.Schema.object({
|
|
54
|
+
image: koishi_1.Schema.object({
|
|
55
|
+
maxCount: koishi_1.Schema.number().min(1).max(8).default(4).description('单次最多返回图片数。'),
|
|
56
|
+
maxDownloadBytes: koishi_1.Schema.number().min(100000).max(20000000).default(8000000).description('单张图片最大下载字节数。'),
|
|
57
|
+
minWidth: koishi_1.Schema.number().min(1).max(4000).default(220).description('候选图片最小宽度。'),
|
|
58
|
+
minHeight: koishi_1.Schema.number().min(1).max(4000).default(220).description('候选图片最小高度。'),
|
|
59
|
+
tempExpireHours: koishi_1.Schema.number().min(1).max(24 * 365).default(24 * 30).description('转存到 ChatLuna Storage 的过期小时数。'),
|
|
60
|
+
userAgent: koishi_1.Schema.string().default('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36').description('下载图片时使用的 User-Agent。')
|
|
61
|
+
}).description('图片')
|
|
62
|
+
}),
|
|
63
|
+
koishi_1.Schema.object({
|
|
64
|
+
storage: koishi_1.Schema.object({
|
|
65
|
+
localFallback: koishi_1.Schema.boolean().default(true).description('没有 chatluna-storage-service 时,是否使用插件本地目录和 HTTP 路由兜底。'),
|
|
66
|
+
localDirectory: koishi_1.Schema.string().default('data/chatluna-image-resolver').description('本地兜底目录,相对 Koishi baseDir。'),
|
|
67
|
+
localPublicPath: koishi_1.Schema.string().default('/chatluna-image-resolver').description('本地兜底 HTTP 路径。')
|
|
68
|
+
}).description('本地转存')
|
|
69
|
+
}),
|
|
70
|
+
koishi_1.Schema.object({
|
|
71
|
+
delivery: koishi_1.Schema.object({
|
|
72
|
+
publicBaseUrl: koishi_1.Schema.string().default('').description('返回给聊天平台拉取图片的公开根地址;用于 NapCat/OneBot Docker 等无法访问 127.0.0.1 的场景,例如 http://172.26.0.1:5140。留空则保留存储服务原 URL。')
|
|
73
|
+
}).description('发送链接')
|
|
74
|
+
}),
|
|
75
|
+
koishi_1.Schema.object({
|
|
76
|
+
webdav: koishi_1.Schema.object({
|
|
77
|
+
enabled: koishi_1.Schema.boolean().default(false).description('是否同步到 WebDAV。'),
|
|
78
|
+
endpoint: koishi_1.Schema.string().default('').description('WebDAV 根地址,例如 https://example.com/dav。'),
|
|
79
|
+
username: koishi_1.Schema.string().default('').description('WebDAV 用户名。'),
|
|
80
|
+
password: koishi_1.Schema.string().role('secret').default('').description('WebDAV 密码。'),
|
|
81
|
+
basePath: koishi_1.Schema.string().default('chatluna-images').description('WebDAV 目录。'),
|
|
82
|
+
publicBaseUrl: koishi_1.Schema.string().default('').description('公开访问根地址;留空则只同步,不返回公开 URL。')
|
|
83
|
+
}).description('WebDAV 同步'),
|
|
84
|
+
debug: koishi_1.Schema.boolean().default(false).description('输出调试日志。')
|
|
85
|
+
})
|
|
86
|
+
]);
|
|
87
|
+
exports.usage = `
|
|
88
|
+
<p><strong>ChatLuna 图片解析器</strong></p>
|
|
89
|
+
<p>注册 <code>image_search_resolve</code> 工具,用于搜索图片、提取候选图片、下载外链、转存为 Koishi 可访问链接,并可选同步到 WebDAV。</p>
|
|
90
|
+
<p>建议让角色预设在图片请求中优先调用该工具,再把返回的 <code>images[].url</code> 放进 <code>character_reply.image</code>。</p>
|
|
91
|
+
`;
|
|
92
|
+
class ImageResolverTool extends tools_1.StructuredTool {
|
|
93
|
+
ctx;
|
|
94
|
+
config;
|
|
95
|
+
name;
|
|
96
|
+
description;
|
|
97
|
+
schema = TOOL_SCHEMA;
|
|
98
|
+
constructor(ctx, config) {
|
|
99
|
+
super({});
|
|
100
|
+
this.ctx = ctx;
|
|
101
|
+
this.config = config;
|
|
102
|
+
this.name = config.tool.name.trim() || 'image_search_resolve';
|
|
103
|
+
this.description = config.tool.description.trim();
|
|
104
|
+
}
|
|
105
|
+
async _call(input) {
|
|
106
|
+
const count = clamp(input.count ?? 1, 1, this.config.image.maxCount);
|
|
107
|
+
const safeMode = input.safeMode ?? true;
|
|
108
|
+
const resolver = new ImageResolver(this.ctx, this.config);
|
|
109
|
+
const result = await resolver.resolve(input.query, count, safeMode);
|
|
110
|
+
return JSON.stringify(result, null, 2);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
class ImageResolver {
|
|
114
|
+
ctx;
|
|
115
|
+
config;
|
|
116
|
+
constructor(ctx, config) {
|
|
117
|
+
this.ctx = ctx;
|
|
118
|
+
this.config = config;
|
|
119
|
+
}
|
|
120
|
+
async resolve(query, count, safeMode) {
|
|
121
|
+
const failures = [];
|
|
122
|
+
const candidates = [];
|
|
123
|
+
const seenPages = new Set();
|
|
124
|
+
const directCandidates = await this.searchDirectImages(query, count, failures);
|
|
125
|
+
candidates.push(...directCandidates.map((candidate) => scoreCandidate(candidate, this.config, safeMode)));
|
|
126
|
+
if (this.config.search.provider !== 'serpapi' && candidates.length < count * 2) {
|
|
127
|
+
const searchResults = await this.search(query, failures);
|
|
128
|
+
for (const result of searchResults.slice(0, this.config.search.maxPages)) {
|
|
129
|
+
if (seenPages.has(result.url))
|
|
130
|
+
continue;
|
|
131
|
+
seenPages.add(result.url);
|
|
132
|
+
if (looksLikeImageUrl(result.url)) {
|
|
133
|
+
candidates.push(scoreCandidate({ url: result.url, sourcePage: result.url, score: 0, reason: 'search-result-url' }, this.config, safeMode));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const pageCandidates = await this.extractFromPage(result.url, failures);
|
|
137
|
+
candidates.push(...pageCandidates.map((candidate) => scoreCandidate(candidate, this.config, safeMode)));
|
|
138
|
+
if (candidates.length >= count * 4)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const ranked = uniqueBy(candidates, (item) => normalizeImageUrl(item.url))
|
|
143
|
+
.filter((item) => item.score > 0)
|
|
144
|
+
.sort((a, b) => b.score - a.score);
|
|
145
|
+
const images = [];
|
|
146
|
+
for (const candidate of ranked) {
|
|
147
|
+
if (images.length >= count)
|
|
148
|
+
break;
|
|
149
|
+
try {
|
|
150
|
+
const downloaded = await this.download(candidate);
|
|
151
|
+
if (!downloaded)
|
|
152
|
+
continue;
|
|
153
|
+
const stored = await this.store(downloaded.buffer, downloaded.filename, downloaded.mime);
|
|
154
|
+
const webdavUrl = await this.syncWebDav(downloaded.buffer, downloaded.filename, downloaded.mime, failures);
|
|
155
|
+
images.push({
|
|
156
|
+
url: stored,
|
|
157
|
+
webdavUrl,
|
|
158
|
+
originalUrl: candidate.url,
|
|
159
|
+
sourcePage: candidate.sourcePage,
|
|
160
|
+
width: candidate.width,
|
|
161
|
+
height: candidate.height,
|
|
162
|
+
bytes: downloaded.buffer.length,
|
|
163
|
+
mime: downloaded.mime
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
failures.push(`download/store failed: ${candidate.url} (${formatError(error)})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ok: images.length > 0,
|
|
172
|
+
query,
|
|
173
|
+
images,
|
|
174
|
+
searchedPages: uniqueBy([
|
|
175
|
+
...directCandidates.map((candidate) => candidate.sourcePage),
|
|
176
|
+
...Array.from(seenPages)
|
|
177
|
+
], (item) => item),
|
|
178
|
+
candidateCount: ranked.length,
|
|
179
|
+
failures: failures.slice(-12),
|
|
180
|
+
hint: images.length > 0
|
|
181
|
+
? 'Use images[].url in character_reply.image. These URLs are already re-hosted by Koishi storage/local fallback.'
|
|
182
|
+
: 'No sendable image was resolved. Reply with the failure summary instead of inventing an image.'
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async search(query, failures) {
|
|
186
|
+
const results = [];
|
|
187
|
+
const provider = this.config.search.provider;
|
|
188
|
+
if ((provider === 'tavily' || provider === 'both' || provider === 'serpapi-fallback') && this.config.search.tavilyApiKey.trim()) {
|
|
189
|
+
results.push(...await this.searchTavily(query, failures));
|
|
190
|
+
}
|
|
191
|
+
if (provider === 'duckduckgo' || provider === 'both' || provider === 'serpapi-fallback' || results.length === 0) {
|
|
192
|
+
results.push(...await this.searchDuckDuckGo(query, failures));
|
|
193
|
+
}
|
|
194
|
+
return uniqueBy(results, (item) => item.url).slice(0, this.config.search.maxSearchResults);
|
|
195
|
+
}
|
|
196
|
+
async searchDirectImages(query, count, failures) {
|
|
197
|
+
const provider = this.config.search.provider;
|
|
198
|
+
if (provider !== 'serpapi' && provider !== 'serpapi-fallback')
|
|
199
|
+
return [];
|
|
200
|
+
if (!this.config.search.serpApiKey.trim()) {
|
|
201
|
+
failures.push('serpapi failed: missing API key');
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
return this.searchSerpApiImages(query, Math.max(count * 4, this.config.search.maxSearchResults), failures);
|
|
205
|
+
}
|
|
206
|
+
async searchSerpApiImages(query, count, failures) {
|
|
207
|
+
try {
|
|
208
|
+
const url = buildSerpApiImagesUrl({
|
|
209
|
+
apiKey: this.config.search.serpApiKey,
|
|
210
|
+
query,
|
|
211
|
+
count: clamp(count, 1, this.config.search.maxSearchResults),
|
|
212
|
+
googleDomain: this.config.search.serpApiGoogleDomain,
|
|
213
|
+
gl: this.config.search.serpApiGl,
|
|
214
|
+
hl: this.config.search.serpApiHl,
|
|
215
|
+
safe: this.config.search.serpApiSafe
|
|
216
|
+
});
|
|
217
|
+
const response = await fetchWithTimeout(url, {
|
|
218
|
+
headers: {
|
|
219
|
+
'Accept': 'application/json',
|
|
220
|
+
'User-Agent': this.config.image.userAgent
|
|
221
|
+
}
|
|
222
|
+
}, this.config.search.pageTimeoutMs);
|
|
223
|
+
const payload = await response.json();
|
|
224
|
+
if (!response.ok)
|
|
225
|
+
throw new Error(payload?.error || `HTTP ${response.status}`);
|
|
226
|
+
if (payload?.error)
|
|
227
|
+
throw new Error(String(payload.error));
|
|
228
|
+
return serpApiImagesToCandidates(payload).slice(0, count);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
failures.push(`serpapi failed: ${formatError(error)}`);
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async searchTavily(query, failures) {
|
|
236
|
+
try {
|
|
237
|
+
const response = await fetchWithTimeout('https://api.tavily.com/search', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({
|
|
241
|
+
api_key: this.config.search.tavilyApiKey,
|
|
242
|
+
query: `${query} image wallpaper fanart`,
|
|
243
|
+
search_depth: 'basic',
|
|
244
|
+
max_results: this.config.search.maxSearchResults
|
|
245
|
+
})
|
|
246
|
+
}, this.config.search.pageTimeoutMs);
|
|
247
|
+
const payload = await response.json();
|
|
248
|
+
return (payload.results ?? []).map((item) => ({
|
|
249
|
+
title: String(item.title ?? ''),
|
|
250
|
+
url: String(item.url ?? ''),
|
|
251
|
+
snippet: String(item.content ?? '')
|
|
252
|
+
})).filter((item) => item.url.startsWith('http'));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
failures.push(`tavily failed: ${formatError(error)}`);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async searchDuckDuckGo(query, failures) {
|
|
260
|
+
const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(`${query} 图片 壁纸 fanart`)}`;
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetchWithTimeout(url, {
|
|
263
|
+
headers: {
|
|
264
|
+
'User-Agent': this.config.image.userAgent,
|
|
265
|
+
'Accept': 'text/html,application/xhtml+xml'
|
|
266
|
+
}
|
|
267
|
+
}, this.config.search.pageTimeoutMs);
|
|
268
|
+
const html = await response.text();
|
|
269
|
+
const results = [];
|
|
270
|
+
const linkPattern = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
271
|
+
let match;
|
|
272
|
+
while ((match = linkPattern.exec(html)) && results.length < this.config.search.maxSearchResults) {
|
|
273
|
+
const decoded = decodeHtml(match[1]);
|
|
274
|
+
const resultUrl = decodeDuckUrl(decoded);
|
|
275
|
+
if (!resultUrl?.startsWith('http'))
|
|
276
|
+
continue;
|
|
277
|
+
results.push({ title: stripTags(match[2]), url: resultUrl });
|
|
278
|
+
}
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
failures.push(`duckduckgo failed: ${formatError(error)}`);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async extractFromPage(url, failures) {
|
|
287
|
+
try {
|
|
288
|
+
const response = await fetchWithTimeout(url, {
|
|
289
|
+
headers: {
|
|
290
|
+
'User-Agent': this.config.image.userAgent,
|
|
291
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
292
|
+
'Referer': new URL(url).origin
|
|
293
|
+
}
|
|
294
|
+
}, this.config.search.pageTimeoutMs);
|
|
295
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
296
|
+
if (contentType.startsWith('image/')) {
|
|
297
|
+
return [{ url, sourcePage: url, score: 0, reason: 'page-is-image' }];
|
|
298
|
+
}
|
|
299
|
+
const html = await response.text();
|
|
300
|
+
const candidates = extractImageCandidates(html, url);
|
|
301
|
+
if (candidates.length || !this.config.search.usePuppeteerFallback || !this.ctx.puppeteer) {
|
|
302
|
+
return candidates;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
failures.push(`html extract failed: ${url} (${formatError(error)})`);
|
|
307
|
+
}
|
|
308
|
+
if (!this.config.search.usePuppeteerFallback || !this.ctx.puppeteer)
|
|
309
|
+
return [];
|
|
310
|
+
return this.extractWithPuppeteer(url, failures);
|
|
311
|
+
}
|
|
312
|
+
async extractWithPuppeteer(url, failures) {
|
|
313
|
+
let page;
|
|
314
|
+
try {
|
|
315
|
+
page = await this.ctx.puppeteer.page();
|
|
316
|
+
await page.setUserAgent?.(this.config.image.userAgent);
|
|
317
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: this.config.search.pageTimeoutMs });
|
|
318
|
+
await page.evaluate(() => window.scrollTo(0, Math.min(document.body.scrollHeight, 2400)));
|
|
319
|
+
await page.waitForTimeout?.(800);
|
|
320
|
+
const raw = await page.evaluate(() => {
|
|
321
|
+
const out = [];
|
|
322
|
+
document.querySelectorAll('img, source').forEach((node) => {
|
|
323
|
+
out.push({
|
|
324
|
+
src: node.currentSrc || node.src || node.getAttribute('src') || node.getAttribute('data-src') || node.getAttribute('data-original'),
|
|
325
|
+
srcset: node.getAttribute('srcset'),
|
|
326
|
+
width: node.naturalWidth || node.width,
|
|
327
|
+
height: node.naturalHeight || node.height
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
return out;
|
|
331
|
+
});
|
|
332
|
+
return raw.flatMap((item) => candidatesFromRawImage(item, url));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
failures.push(`puppeteer extract failed: ${url} (${formatError(error)})`);
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
await page?.close?.().catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async download(candidate) {
|
|
343
|
+
const headers = {
|
|
344
|
+
'User-Agent': this.config.image.userAgent,
|
|
345
|
+
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
|
346
|
+
'Referer': candidate.sourcePage
|
|
347
|
+
};
|
|
348
|
+
const response = await fetchWithTimeout(candidate.url, { headers }, this.config.search.pageTimeoutMs);
|
|
349
|
+
if (!response.ok)
|
|
350
|
+
throw new Error(`HTTP ${response.status}`);
|
|
351
|
+
const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
352
|
+
if (!mime.startsWith('image/'))
|
|
353
|
+
throw new Error(`not image: ${mime || 'unknown content-type'}`);
|
|
354
|
+
const length = Number(response.headers.get('content-length') ?? '0');
|
|
355
|
+
if (length > this.config.image.maxDownloadBytes)
|
|
356
|
+
throw new Error(`image too large: ${length}`);
|
|
357
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
358
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
359
|
+
if (buffer.length > this.config.image.maxDownloadBytes)
|
|
360
|
+
throw new Error(`image too large: ${buffer.length}`);
|
|
361
|
+
const ext = mimeToExt(mime) || extFromUrl(candidate.url) || '.jpg';
|
|
362
|
+
const hash = (0, node_crypto_1.createHash)('sha1').update(buffer).digest('hex').slice(0, 12);
|
|
363
|
+
return { buffer, mime, filename: `resolved-${hash}${ext}` };
|
|
364
|
+
}
|
|
365
|
+
async store(buffer, filename, mime) {
|
|
366
|
+
if (this.ctx.chatluna_storage?.createTempFile) {
|
|
367
|
+
const stored = await this.ctx.chatluna_storage.createTempFile(buffer, filename, this.config.image.tempExpireHours, mime);
|
|
368
|
+
return rewriteUrlBase(stored.url, this.config.delivery.publicBaseUrl);
|
|
369
|
+
}
|
|
370
|
+
if (!this.config.storage.localFallback) {
|
|
371
|
+
throw new Error('chatluna-storage-service is not available and local fallback is disabled');
|
|
372
|
+
}
|
|
373
|
+
const dir = (0, node_path_1.join)(this.ctx.baseDir, this.config.storage.localDirectory);
|
|
374
|
+
await (0, promises_1.mkdir)(dir, { recursive: true });
|
|
375
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(dir, filename), buffer);
|
|
376
|
+
const base = trimTrailingSlash(this.ctx.server?.selfUrl ?? '');
|
|
377
|
+
return rewriteUrlBase(`${base}${this.config.storage.localPublicPath}/${filename}`, this.config.delivery.publicBaseUrl);
|
|
378
|
+
}
|
|
379
|
+
async syncWebDav(buffer, filename, mime, failures) {
|
|
380
|
+
const cfg = this.config.webdav;
|
|
381
|
+
if (!cfg.enabled || !cfg.endpoint.trim())
|
|
382
|
+
return undefined;
|
|
383
|
+
try {
|
|
384
|
+
const basePath = trimSlashes(cfg.basePath);
|
|
385
|
+
const uploadUrl = `${trimTrailingSlash(cfg.endpoint)}/${basePath ? `${basePath}/` : ''}${encodeURIComponent(filename)}`;
|
|
386
|
+
await ensureWebDavCollections(cfg, basePath, this.config.search.pageTimeoutMs);
|
|
387
|
+
const response = await fetchWithTimeout(uploadUrl, {
|
|
388
|
+
method: 'PUT',
|
|
389
|
+
headers: {
|
|
390
|
+
'Authorization': basicAuth(cfg.username, cfg.password),
|
|
391
|
+
'Content-Type': mime,
|
|
392
|
+
'Content-Length': String(buffer.length)
|
|
393
|
+
},
|
|
394
|
+
body: buffer
|
|
395
|
+
}, this.config.search.pageTimeoutMs);
|
|
396
|
+
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
|
397
|
+
throw new Error(`WebDAV PUT HTTP ${response.status}`);
|
|
398
|
+
}
|
|
399
|
+
if (!cfg.publicBaseUrl.trim())
|
|
400
|
+
return undefined;
|
|
401
|
+
return `${trimTrailingSlash(cfg.publicBaseUrl)}/${basePath ? `${basePath}/` : ''}${encodeURIComponent(filename)}`;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
failures.push(`webdav sync failed: ${formatError(error)}`);
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function apply(ctx, config) {
|
|
410
|
+
if (config.storage.localFallback) {
|
|
411
|
+
ctx.inject(['server'], (ctx2) => {
|
|
412
|
+
if (!ctx2.server)
|
|
413
|
+
return;
|
|
414
|
+
ctx2.server.get(`${config.storage.localPublicPath}/:name`, async (koa) => {
|
|
415
|
+
const filename = String(koa.params.name ?? '');
|
|
416
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
|
|
417
|
+
koa.status = 400;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const file = await (0, promises_1.readFile)((0, node_path_1.join)(ctx.baseDir, config.storage.localDirectory, filename));
|
|
422
|
+
koa.set('Content-Type', mimeFromFilename(filename));
|
|
423
|
+
koa.body = file;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
koa.status = 404;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const registerTool = (ctx2) => {
|
|
432
|
+
if (!config.tool.enabled)
|
|
433
|
+
return;
|
|
434
|
+
if (!ctx2.chatluna?.platform?.registerTool) {
|
|
435
|
+
ctx2.logger(exports.name).warn('ChatLuna platform is unavailable; skip registering image resolver tool.');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const toolName = config.tool.name.trim() || 'image_search_resolve';
|
|
439
|
+
ctx2.effect(() => ctx2.chatluna.platform.registerTool(toolName, {
|
|
440
|
+
description: config.tool.description,
|
|
441
|
+
selector() {
|
|
442
|
+
return true;
|
|
443
|
+
},
|
|
444
|
+
createTool() {
|
|
445
|
+
return new ImageResolverTool(ctx2, config);
|
|
446
|
+
},
|
|
447
|
+
meta: {
|
|
448
|
+
source: 'extension',
|
|
449
|
+
group: 'image-resolver',
|
|
450
|
+
tags: ['image-resolver', 'image-search', 'webdav'],
|
|
451
|
+
defaultAvailability: {
|
|
452
|
+
enabled: true,
|
|
453
|
+
main: true,
|
|
454
|
+
chatluna: true,
|
|
455
|
+
characterScope: 'all'
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}));
|
|
459
|
+
ctx2.logger(exports.name).info('registered ChatLuna tool: %s', toolName);
|
|
460
|
+
};
|
|
461
|
+
ctx.inject(['chatluna'], registerTool);
|
|
462
|
+
ctx.command('image-resolver <query:text>', '搜索并转存图片为 Koishi 可访问链接')
|
|
463
|
+
.option('count', '-c <count:number> 返回图片数量')
|
|
464
|
+
.action(async ({ session, options }, query) => {
|
|
465
|
+
if (!query?.trim())
|
|
466
|
+
return '请输入搜索词。';
|
|
467
|
+
const count = clamp(Number(options?.count ?? 1) || 1, 1, config.image.maxCount);
|
|
468
|
+
const resolver = new ImageResolver(ctx, config);
|
|
469
|
+
const result = await resolver.resolve(query, count, true);
|
|
470
|
+
if (!result.ok) {
|
|
471
|
+
return `没有解析到可发送图片:${result.failures.slice(-3).join(';') || '无可用候选'}`;
|
|
472
|
+
}
|
|
473
|
+
if (!session) {
|
|
474
|
+
return result.images.map((image) => image.url).join('\n');
|
|
475
|
+
}
|
|
476
|
+
for (const image of result.images) {
|
|
477
|
+
await session.send(koishi_1.h.image(image.url));
|
|
478
|
+
}
|
|
479
|
+
return `已转存 ${result.images.length} 张图片。`;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function extractImageCandidates(html, pageUrl) {
|
|
483
|
+
const candidates = [];
|
|
484
|
+
const metaPattern = /<meta[^>]+(?:property|name)=["'](?:og:image|twitter:image|og:image:secure_url)["'][^>]+content=["']([^"']+)["'][^>]*>/gi;
|
|
485
|
+
let match;
|
|
486
|
+
while ((match = metaPattern.exec(html))) {
|
|
487
|
+
pushCandidate(candidates, match[1], pageUrl, 'meta-image');
|
|
488
|
+
}
|
|
489
|
+
const imgPattern = /<(?:img|source)\b([^>]+)>/gi;
|
|
490
|
+
while ((match = imgPattern.exec(html))) {
|
|
491
|
+
const attrs = parseAttributes(match[1]);
|
|
492
|
+
candidates.push(...candidatesFromRawImage({
|
|
493
|
+
src: attrs.currentSrc || attrs.src || attrs['data-src'] || attrs['data-original'] || attrs['data-lazy-src'],
|
|
494
|
+
srcset: attrs.srcset || attrs['data-srcset'],
|
|
495
|
+
width: Number(attrs.width || 0),
|
|
496
|
+
height: Number(attrs.height || 0)
|
|
497
|
+
}, pageUrl));
|
|
498
|
+
}
|
|
499
|
+
const bgPattern = /url\((["']?)([^"')]+)\1\)/gi;
|
|
500
|
+
while ((match = bgPattern.exec(html))) {
|
|
501
|
+
pushCandidate(candidates, match[2], pageUrl, 'css-url');
|
|
502
|
+
}
|
|
503
|
+
return uniqueBy(candidates, (item) => normalizeImageUrl(item.url));
|
|
504
|
+
}
|
|
505
|
+
function candidatesFromRawImage(raw, pageUrl) {
|
|
506
|
+
const out = [];
|
|
507
|
+
if (raw?.src)
|
|
508
|
+
pushCandidate(out, raw.src, pageUrl, 'img-src', raw.width, raw.height);
|
|
509
|
+
if (raw?.srcset) {
|
|
510
|
+
for (const item of parseSrcset(String(raw.srcset))) {
|
|
511
|
+
pushCandidate(out, item.url, pageUrl, `srcset-${item.descriptor}`, raw.width, raw.height);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
function pushCandidate(out, value, pageUrl, reason, width, height) {
|
|
517
|
+
const url = absolutizeUrl(decodeHtml(value.trim()), pageUrl);
|
|
518
|
+
if (!url || !url.startsWith('http'))
|
|
519
|
+
return;
|
|
520
|
+
out.push({ url, sourcePage: pageUrl, score: 0, width, height, reason });
|
|
521
|
+
}
|
|
522
|
+
function buildSerpApiImagesUrl(options) {
|
|
523
|
+
const url = new URL('https://serpapi.com/search.json');
|
|
524
|
+
url.searchParams.set('engine', 'google_images');
|
|
525
|
+
url.searchParams.set('api_key', options.apiKey);
|
|
526
|
+
url.searchParams.set('q', options.query);
|
|
527
|
+
url.searchParams.set('num', String(clamp(Math.floor(options.count), 1, 100)));
|
|
528
|
+
if (options.googleDomain?.trim())
|
|
529
|
+
url.searchParams.set('google_domain', options.googleDomain.trim());
|
|
530
|
+
if (options.gl?.trim())
|
|
531
|
+
url.searchParams.set('gl', options.gl.trim());
|
|
532
|
+
if (options.hl?.trim())
|
|
533
|
+
url.searchParams.set('hl', options.hl.trim());
|
|
534
|
+
if (options.safe)
|
|
535
|
+
url.searchParams.set('safe', options.safe);
|
|
536
|
+
return url.href;
|
|
537
|
+
}
|
|
538
|
+
function serpApiImagesToCandidates(payload) {
|
|
539
|
+
const results = Array.isArray(payload?.images_results) ? payload.images_results : [];
|
|
540
|
+
const candidates = [];
|
|
541
|
+
for (const item of results) {
|
|
542
|
+
const sourcePage = bestSourcePage(item);
|
|
543
|
+
const width = numberOrUndefined(item?.original_width ?? item?.width);
|
|
544
|
+
const height = numberOrUndefined(item?.original_height ?? item?.height);
|
|
545
|
+
if (typeof item?.original === 'string') {
|
|
546
|
+
pushCandidate(candidates, item.original, sourcePage, 'serpapi-original', width, height);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (typeof item?.thumbnail === 'string') {
|
|
550
|
+
pushCandidate(candidates, item.thumbnail, sourcePage, 'serpapi-thumbnail', width, height);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return uniqueBy(candidates, (item) => normalizeImageUrl(item.url));
|
|
554
|
+
}
|
|
555
|
+
function bestSourcePage(item) {
|
|
556
|
+
for (const value of [item?.link, item?.source, item?.original]) {
|
|
557
|
+
if (typeof value === 'string' && /^https?:\/\//i.test(value))
|
|
558
|
+
return value;
|
|
559
|
+
}
|
|
560
|
+
return 'https://serpapi.com/';
|
|
561
|
+
}
|
|
562
|
+
function numberOrUndefined(value) {
|
|
563
|
+
const number = Number(value);
|
|
564
|
+
return Number.isFinite(number) && number > 0 ? number : undefined;
|
|
565
|
+
}
|
|
566
|
+
function scoreCandidate(candidate, config, safeMode) {
|
|
567
|
+
const url = normalizeImageUrl(candidate.url);
|
|
568
|
+
let score = 20;
|
|
569
|
+
if (/^https:/.test(url))
|
|
570
|
+
score += 5;
|
|
571
|
+
if (looksLikeImageUrl(url))
|
|
572
|
+
score += 20;
|
|
573
|
+
if (candidate.reason === 'serpapi-original')
|
|
574
|
+
score += 35;
|
|
575
|
+
if (candidate.reason === 'serpapi-thumbnail')
|
|
576
|
+
score -= 18;
|
|
577
|
+
if (candidate.reason.startsWith('meta'))
|
|
578
|
+
score += 10;
|
|
579
|
+
if (candidate.reason.startsWith('srcset'))
|
|
580
|
+
score += 8;
|
|
581
|
+
if (candidate.width && candidate.width >= config.image.minWidth)
|
|
582
|
+
score += 8;
|
|
583
|
+
if (candidate.height && candidate.height >= config.image.minHeight)
|
|
584
|
+
score += 8;
|
|
585
|
+
if (candidate.width && candidate.height) {
|
|
586
|
+
const pixels = candidate.width * candidate.height;
|
|
587
|
+
if (pixels > 1_000_000)
|
|
588
|
+
score += 14;
|
|
589
|
+
else if (pixels > 300_000)
|
|
590
|
+
score += 8;
|
|
591
|
+
}
|
|
592
|
+
if (/\b(logo|icon|avatar|face|emoji|sprite|placeholder|blank|loading)\b/i.test(url))
|
|
593
|
+
score -= 35;
|
|
594
|
+
if (/\.(svg)(?:[?#].*)?$/i.test(url))
|
|
595
|
+
score -= safeMode ? 40 : 12;
|
|
596
|
+
if (/\bthumb|thumbnail|small|_s\b/i.test(url))
|
|
597
|
+
score -= 8;
|
|
598
|
+
if (candidate.width && candidate.width < config.image.minWidth)
|
|
599
|
+
score -= 20;
|
|
600
|
+
if (candidate.height && candidate.height < config.image.minHeight)
|
|
601
|
+
score -= 20;
|
|
602
|
+
return { ...candidate, url, score };
|
|
603
|
+
}
|
|
604
|
+
function parseSrcset(srcset) {
|
|
605
|
+
return srcset.split(',').map((part) => {
|
|
606
|
+
const [url, descriptor = ''] = part.trim().split(/\s+/, 2);
|
|
607
|
+
return { url, descriptor };
|
|
608
|
+
}).filter((item) => item.url);
|
|
609
|
+
}
|
|
610
|
+
function parseAttributes(raw) {
|
|
611
|
+
const attrs = {};
|
|
612
|
+
const pattern = /([:\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
613
|
+
let match;
|
|
614
|
+
while ((match = pattern.exec(raw))) {
|
|
615
|
+
attrs[match[1]] = decodeHtml(match[2] ?? match[3] ?? match[4] ?? '');
|
|
616
|
+
}
|
|
617
|
+
return attrs;
|
|
618
|
+
}
|
|
619
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
620
|
+
const controller = new AbortController();
|
|
621
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
622
|
+
try {
|
|
623
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
clearTimeout(timer);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function ensureWebDavCollections(cfg, basePath, timeoutMs) {
|
|
630
|
+
if (!basePath)
|
|
631
|
+
return;
|
|
632
|
+
let current = trimTrailingSlash(cfg.endpoint);
|
|
633
|
+
for (const segment of basePath.split('/').filter(Boolean)) {
|
|
634
|
+
current = `${current}/${encodeURIComponent(segment)}`;
|
|
635
|
+
await fetchWithTimeout(current, {
|
|
636
|
+
method: 'MKCOL',
|
|
637
|
+
headers: { 'Authorization': basicAuth(cfg.username, cfg.password) }
|
|
638
|
+
}, timeoutMs).catch(() => undefined);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function decodeDuckUrl(url) {
|
|
642
|
+
try {
|
|
643
|
+
const parsed = new URL(url, 'https://duckduckgo.com');
|
|
644
|
+
const uddg = parsed.searchParams.get('uddg');
|
|
645
|
+
return uddg ? decodeURIComponent(uddg) : parsed.href;
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return url;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function absolutizeUrl(raw, base) {
|
|
652
|
+
if (!raw || raw.startsWith('data:') || raw.startsWith('blob:'))
|
|
653
|
+
return '';
|
|
654
|
+
if (raw.startsWith('//'))
|
|
655
|
+
return `https:${raw}`;
|
|
656
|
+
try {
|
|
657
|
+
return new URL(raw, base).href;
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return '';
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function normalizeImageUrl(url) {
|
|
664
|
+
return url.replace(/&/g, '&');
|
|
665
|
+
}
|
|
666
|
+
function rewriteUrlBase(url, publicBaseUrl) {
|
|
667
|
+
const base = trimTrailingSlash(publicBaseUrl.trim());
|
|
668
|
+
if (!base)
|
|
669
|
+
return url;
|
|
670
|
+
try {
|
|
671
|
+
const parsed = new URL(url);
|
|
672
|
+
return `${base}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
const path = url.startsWith('/') ? url : `/${url}`;
|
|
676
|
+
return `${base}${path}`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function looksLikeImageUrl(url) {
|
|
680
|
+
return /\.(?:jpe?g|png|webp|gif|avif)(?:[?#].*)?$/i.test(url);
|
|
681
|
+
}
|
|
682
|
+
function extFromUrl(url) {
|
|
683
|
+
try {
|
|
684
|
+
const ext = (0, node_path_1.extname)(new URL(url).pathname).toLowerCase();
|
|
685
|
+
return ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.avif'].includes(ext) ? ext : '';
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
return '';
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function mimeToExt(mime) {
|
|
692
|
+
if (mime.includes('jpeg'))
|
|
693
|
+
return '.jpg';
|
|
694
|
+
if (mime.includes('png'))
|
|
695
|
+
return '.png';
|
|
696
|
+
if (mime.includes('webp'))
|
|
697
|
+
return '.webp';
|
|
698
|
+
if (mime.includes('gif'))
|
|
699
|
+
return '.gif';
|
|
700
|
+
if (mime.includes('avif'))
|
|
701
|
+
return '.avif';
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
function mimeFromFilename(filename) {
|
|
705
|
+
const ext = (0, node_path_1.extname)(filename).toLowerCase();
|
|
706
|
+
if (ext === '.png')
|
|
707
|
+
return 'image/png';
|
|
708
|
+
if (ext === '.webp')
|
|
709
|
+
return 'image/webp';
|
|
710
|
+
if (ext === '.gif')
|
|
711
|
+
return 'image/gif';
|
|
712
|
+
if (ext === '.avif')
|
|
713
|
+
return 'image/avif';
|
|
714
|
+
return 'image/jpeg';
|
|
715
|
+
}
|
|
716
|
+
function stripTags(value) {
|
|
717
|
+
return decodeHtml(value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
718
|
+
}
|
|
719
|
+
function decodeHtml(value) {
|
|
720
|
+
return value
|
|
721
|
+
.replace(/&/g, '&')
|
|
722
|
+
.replace(/"/g, '"')
|
|
723
|
+
.replace(/'/g, "'")
|
|
724
|
+
.replace(/</g, '<')
|
|
725
|
+
.replace(/>/g, '>');
|
|
726
|
+
}
|
|
727
|
+
function basicAuth(username, password) {
|
|
728
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
729
|
+
}
|
|
730
|
+
function trimTrailingSlash(value) {
|
|
731
|
+
return value.replace(/\/+$/, '');
|
|
732
|
+
}
|
|
733
|
+
function trimSlashes(value) {
|
|
734
|
+
return value.replace(/^\/+|\/+$/g, '');
|
|
735
|
+
}
|
|
736
|
+
function uniqueBy(items, getKey) {
|
|
737
|
+
const seen = new Set();
|
|
738
|
+
const out = [];
|
|
739
|
+
for (const item of items) {
|
|
740
|
+
const key = getKey(item);
|
|
741
|
+
if (!key || seen.has(key))
|
|
742
|
+
continue;
|
|
743
|
+
seen.add(key);
|
|
744
|
+
out.push(item);
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
function clamp(value, min, max) {
|
|
749
|
+
return Math.min(max, Math.max(min, value));
|
|
750
|
+
}
|
|
751
|
+
function formatError(error) {
|
|
752
|
+
return error instanceof Error ? error.message : String(error);
|
|
753
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-chatluna-image-resolver",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ChatLuna image search resolver that extracts, downloads, stores, and optionally syncs images to WebDAV.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/yabo083/koishi-plugin-chatluna-image-resolver.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/yabo083/koishi-plugin-chatluna-image-resolver/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/yabo083/koishi-plugin-chatluna-image-resolver#readme",
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"types": "lib/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib/index.js",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"koishi",
|
|
26
|
+
"koishi-plugin",
|
|
27
|
+
"chatluna",
|
|
28
|
+
"image",
|
|
29
|
+
"resolver",
|
|
30
|
+
"webdav"
|
|
31
|
+
],
|
|
32
|
+
"author": "yabo",
|
|
33
|
+
"license": "AGPL-3.0-only",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"test": "npm run build && node --test tests/*.test.cjs",
|
|
40
|
+
"prepublishOnly": "npm run build"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"koishi": "^4.18.0",
|
|
44
|
+
"koishi-plugin-chatluna-character": "*"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"koishi-plugin-chatluna-character": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@langchain/core": "^0.3.0",
|
|
53
|
+
"zod": "^3.23.8"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.7.5",
|
|
57
|
+
"typescript": "^5.6.2"
|
|
58
|
+
},
|
|
59
|
+
"koishi": {
|
|
60
|
+
"description": {
|
|
61
|
+
"zh": "ChatLuna 图片解析器:搜索图片、提取候选、下载转存为 Koishi 本地链接,并可同步到 WebDAV。"
|
|
62
|
+
},
|
|
63
|
+
"service": {
|
|
64
|
+
"optional": [
|
|
65
|
+
"chatluna",
|
|
66
|
+
"chatluna_storage",
|
|
67
|
+
"puppeteer"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|