kokkoro-plugin-saucenao 2.0.1 → 2.0.3
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/README.md +19 -16
- package/package.json +1 -1
- package/src/index.ts +9 -12
- package/src/service.ts +25 -12
- package/src/util.ts +14 -13
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
SauceNAO 图片搜索,查找动漫、漫画、插画等图片的来源。
|
|
4
4
|
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> 使用 SauceNAO API 前,需要先[申请 API Key](https://saucenao.com/user.php?page=search-api),并完成[环境变量配置](#环境变量)。
|
|
7
|
+
|
|
5
8
|
## 安装
|
|
6
9
|
|
|
7
10
|
```shell
|
|
@@ -14,16 +17,30 @@ bun add kokkoro-plugin-saucenao
|
|
|
14
17
|
/搜图
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
发送「/搜图」指令时附带图片,或引用包含图片的消息回复该指令。插件搜索首张图片的来源,并通过 QQ Markdown 返回结果,默认显示相似度最高的 3 条。
|
|
18
21
|
|
|
19
22
|
## 快捷方式
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
「搜图」与「/搜图」的用法相同。在普通群消息中使用快捷方式,需要开启「获取群内全部消息」权限。
|
|
22
25
|
|
|
23
26
|
```text
|
|
24
27
|
搜图
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
其他插件可以从 `service` 入口导入 `fetchImageSources()` 搜索图片来源,再从 `util` 入口导入 `createMarkdown()` 生成 QQ Markdown:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { fetchImageSources } from 'kokkoro-plugin-saucenao/service';
|
|
36
|
+
import { createMarkdown } from 'kokkoro-plugin-saucenao/util';
|
|
37
|
+
|
|
38
|
+
const { results } = await fetchImageSources('https://example.com/image.jpg');
|
|
39
|
+
const markdown = await createMarkdown(results);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`fetchImageSources()` 返回 SauceNAO 的完整响应,可通过第二个参数传入日志记录器,记录完整请求参数和接口响应。`service` 入口同时导出 `SauceNao` 和 `ImageSource` 类型。
|
|
43
|
+
|
|
27
44
|
## 环境变量
|
|
28
45
|
|
|
29
46
|
使用插件前,需要在项目根目录的 `.env` 文件中填写 [SauceNAO API Key](https://saucenao.com/user.php?page=search-api)。
|
|
@@ -37,17 +54,3 @@ SAUCENAO_SIMILARITY_THRESHOLD=50
|
|
|
37
54
|
`SAUCENAO_NUMRES` 表示返回结果数量,默认值为 `3`。
|
|
38
55
|
|
|
39
56
|
`SAUCENAO_SIMILARITY_THRESHOLD` 表示显示原缩略图的最低相似度,默认值为 `50`。低于该数值时,插件会使用表情包替换缩略图,避免搜出奇怪的图社死。
|
|
40
|
-
|
|
41
|
-
## API
|
|
42
|
-
|
|
43
|
-
其他插件可以从 `service` 入口导入 `fetchImageSources()` 搜索图片来源,再从 `util` 入口导入 `createMarkdown()` 生成 QQ Markdown:
|
|
44
|
-
|
|
45
|
-
```typescript
|
|
46
|
-
import { fetchImageSources } from 'kokkoro-plugin-saucenao/service';
|
|
47
|
-
import { createMarkdown } from 'kokkoro-plugin-saucenao/util';
|
|
48
|
-
|
|
49
|
-
const { results } = await fetchImageSources('https://example.com/image.jpg');
|
|
50
|
-
const markdown = await createMarkdown(results);
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
`fetchImageSources()` 返回 SauceNAO 的完整响应。`service` 入口同时导出 `SauceNao` 和 `ImageSource` 类型。
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
1
|
import { useCommand, useLogger } from '@kokkoro/core';
|
|
2
2
|
|
|
3
|
-
import { fetchImageSources
|
|
3
|
+
import { fetchImageSources } from './service';
|
|
4
4
|
import { createMarkdown } from './util';
|
|
5
5
|
|
|
6
6
|
const logger = useLogger();
|
|
7
7
|
|
|
8
8
|
export default () => {
|
|
9
9
|
useCommand('/搜图', async context => {
|
|
10
|
-
const
|
|
10
|
+
const [reference] = context.msg_elements ?? [];
|
|
11
|
+
const attachments = context.message_type === 103 ? reference?.attachments : context.attachments;
|
|
12
|
+
const images = attachments?.filter(attachment => attachment.content_type?.startsWith('image/')) ?? [];
|
|
13
|
+
const [image] = images;
|
|
11
14
|
|
|
12
15
|
if (!image?.url) {
|
|
13
|
-
throw new Error('
|
|
16
|
+
throw new Error('未检测到图片,请在指令消息中附带图片,或引用包含图片的消息后重试。');
|
|
14
17
|
}
|
|
15
|
-
|
|
16
|
-
method: 'POST',
|
|
17
|
-
url: SAUCENAO_API,
|
|
18
|
-
payload: { url: image.url },
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
const result = await fetchImageSources(image.url);
|
|
18
|
+
const result = await fetchImageSources(image.url, logger);
|
|
22
19
|
const { results } = result;
|
|
20
|
+
const notice = images.length > 1 ? '检测到消息中包含多张图片,已选取首张图片进行识别。' : undefined;
|
|
23
21
|
|
|
24
|
-
logger.debug('收到 SauceNAO 响应', result);
|
|
25
22
|
logger.info('已找到图片来源', { count: results.length });
|
|
26
23
|
|
|
27
24
|
await context.reply({
|
|
28
25
|
msg_type: 2,
|
|
29
26
|
markdown: {
|
|
30
|
-
content: await createMarkdown(results),
|
|
27
|
+
content: await createMarkdown(results, notice),
|
|
31
28
|
},
|
|
32
29
|
});
|
|
33
30
|
}).shortcut('搜图');
|
package/src/service.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type Logger } from '@kokkoro/core';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* SauceNAO JSON API 搜索响应。
|
|
3
5
|
*
|
|
@@ -23,7 +25,7 @@ export interface SauceNao {
|
|
|
23
25
|
/** 请求失败时返回的错误信息。 */
|
|
24
26
|
readonly message?: string;
|
|
25
27
|
/** `numres` 请求的结果数量。 */
|
|
26
|
-
readonly results_requested:
|
|
28
|
+
readonly results_requested: number;
|
|
27
29
|
/** 参与搜索的索引状态,以索引编号为键。 */
|
|
28
30
|
readonly index: Readonly<
|
|
29
31
|
Record<
|
|
@@ -115,37 +117,48 @@ export interface ImageSource {
|
|
|
115
117
|
};
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
const { SAUCENAO_API_KEY, SAUCENAO_NUMRES = 3 } = import.meta.env;
|
|
120
|
+
const { SAUCENAO_API_KEY, SAUCENAO_NUMRES = '3' } = import.meta.env;
|
|
119
121
|
/** SauceNAO JSON API 的请求地址。 */
|
|
120
122
|
export const SAUCENAO_API = 'https://saucenao.com/search.php';
|
|
121
123
|
|
|
122
124
|
/** 使用 SauceNAO 搜索图片来源,并返回完整的接口响应。 */
|
|
123
|
-
export async function fetchImageSources(
|
|
125
|
+
export async function fetchImageSources(url: string, logger?: Logger): Promise<SauceNao> {
|
|
124
126
|
if (!SAUCENAO_API_KEY) {
|
|
125
127
|
throw new Error('未配置 SAUCENAO_API_KEY 环境变量');
|
|
126
128
|
}
|
|
127
|
-
const
|
|
129
|
+
const endpoint = new URL(SAUCENAO_API);
|
|
130
|
+
const payload = {
|
|
131
|
+
api_key: SAUCENAO_API_KEY,
|
|
132
|
+
output_type: '2',
|
|
133
|
+
numres: SAUCENAO_NUMRES,
|
|
134
|
+
db: '999',
|
|
135
|
+
url,
|
|
136
|
+
};
|
|
137
|
+
const params = new URLSearchParams(payload);
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
endpoint.search = params.toString();
|
|
140
|
+
logger?.debug('发送 SauceNAO 请求', {
|
|
141
|
+
method: 'GET',
|
|
142
|
+
url: SAUCENAO_API,
|
|
143
|
+
payload,
|
|
144
|
+
});
|
|
134
145
|
|
|
135
|
-
const response = await fetch(
|
|
146
|
+
const response = await fetch(endpoint);
|
|
136
147
|
|
|
137
148
|
if (!response.ok) {
|
|
138
|
-
throw new Error(
|
|
149
|
+
throw new Error(`SauceNAO 请求失败,HTTP 状态码 ${response.status}`);
|
|
139
150
|
}
|
|
140
151
|
const result = <SauceNao>await response.json();
|
|
141
152
|
const { header, results } = result;
|
|
142
153
|
|
|
154
|
+
logger?.debug('收到 SauceNAO 响应', result);
|
|
155
|
+
|
|
143
156
|
if (header.status !== 0) {
|
|
144
157
|
throw new Error(header.message ?? `SauceNAO 搜索失败,状态码 ${header.status}`);
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
if (results.length === 0) {
|
|
148
|
-
throw new Error('
|
|
161
|
+
throw new Error('未找到图片来源');
|
|
149
162
|
}
|
|
150
163
|
return result;
|
|
151
164
|
}
|
package/src/util.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Image } from 'bun';
|
|
|
2
2
|
|
|
3
3
|
import { type ImageSource } from './service';
|
|
4
4
|
|
|
5
|
-
const { SAUCENAO_SIMILARITY_THRESHOLD = 50 } = import.meta.env;
|
|
5
|
+
const { SAUCENAO_SIMILARITY_THRESHOLD = '50' } = import.meta.env;
|
|
6
6
|
const LOW_SIMILARITY_THUMBNAIL = 'https://kokkoro.js.org/saucenao-low-similarity.jpg';
|
|
7
7
|
const similarityThreshold = Number(SAUCENAO_SIMILARITY_THRESHOLD);
|
|
8
8
|
|
|
@@ -28,41 +28,42 @@ function getUrl(data: ImageSource['data']) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** 将 SauceNAO 搜索结果转换为 QQ Markdown
|
|
32
|
-
export async function createMarkdown(results: readonly ImageSource[]): Promise<string> {
|
|
31
|
+
/** 将 SauceNAO 搜索结果转换为 QQ Markdown,可在标题下方展示提示。 */
|
|
32
|
+
export async function createMarkdown(results: readonly ImageSource[], notice?: string): Promise<string> {
|
|
33
|
+
const lines = ['## SauceNAO 搜图结果'];
|
|
34
|
+
|
|
35
|
+
if (notice) {
|
|
36
|
+
lines.push('', `> ${notice}`);
|
|
37
|
+
}
|
|
33
38
|
const content = await Promise.all(
|
|
34
39
|
results.map(async ({ data, header }, index) => {
|
|
35
40
|
const { index_name: indexName, similarity, thumbnail } = header;
|
|
36
41
|
const platform = getPlatform(indexName);
|
|
37
42
|
const title = getTitle(data) || platform;
|
|
38
43
|
const url = getUrl(data);
|
|
39
|
-
const lines = [
|
|
44
|
+
const lines = [`### ${index + 1}. ${title}`, `- 相似度:${similarity}%`, `- 平台:${platform}`];
|
|
40
45
|
|
|
41
46
|
if (thumbnail) {
|
|
42
47
|
if (Number(similarity) < similarityThreshold) {
|
|
43
|
-
lines.push('
|
|
48
|
+
lines.push('', ``);
|
|
44
49
|
} else {
|
|
45
50
|
const response = await fetch(thumbnail);
|
|
46
51
|
|
|
47
52
|
if (!response.ok) {
|
|
48
|
-
throw new Error(
|
|
53
|
+
throw new Error(`缩略图下载失败,HTTP 状态码 ${response.status}`);
|
|
49
54
|
}
|
|
50
55
|
const { height, width } = await new Image(await response.blob()).metadata();
|
|
51
56
|
|
|
52
|
-
lines.push('
|
|
57
|
+
lines.push('', ``);
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
if (url) {
|
|
57
|
-
lines.push(`[查看来源](${url})`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (index < results.length - 1) {
|
|
61
|
-
lines.push('***');
|
|
62
|
+
lines.push('', `[查看来源](${url})`);
|
|
62
63
|
}
|
|
63
64
|
return lines.join('\n');
|
|
64
65
|
}),
|
|
65
66
|
);
|
|
66
67
|
|
|
67
|
-
return ['
|
|
68
|
+
return [lines.join('\n'), ...content].join('\n***\n');
|
|
68
69
|
}
|