kokkoro-plugin-saucenao 2.0.0 → 2.0.1
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 +15 -1
- package/package.json +8 -4
- package/src/index.ts +22 -218
- package/src/service.ts +151 -0
- package/src/util.ts +68 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# kokkoro-plugin-saucenao
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SauceNAO 图片搜索,查找动漫、漫画、插画等图片的来源。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -37,3 +37,17 @@ SAUCENAO_SIMILARITY_THRESHOLD=50
|
|
|
37
37
|
`SAUCENAO_NUMRES` 表示返回结果数量,默认值为 `3`。
|
|
38
38
|
|
|
39
39
|
`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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kokkoro-plugin-saucenao",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "SauceNAO
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "SauceNAO 图片搜索,查找动漫、漫画、插画等图片的来源。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
7
7
|
"kokkoro",
|
|
@@ -17,12 +17,16 @@
|
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"author": "Yuki <admin@yuki.sh>",
|
|
19
19
|
"type": "module",
|
|
20
|
-
"exports":
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./service": "./src/service.ts",
|
|
23
|
+
"./util": "./src/util.ts"
|
|
24
|
+
},
|
|
21
25
|
"files": [
|
|
22
26
|
"src"
|
|
23
27
|
],
|
|
24
28
|
"peerDependencies": {
|
|
25
|
-
"@kokkoro/core": "^3.1.
|
|
29
|
+
"@kokkoro/core": "^3.1.4",
|
|
26
30
|
"typescript": "^6.0.3"
|
|
27
31
|
}
|
|
28
32
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,222 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCommand, useLogger } from '@kokkoro/core';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { fetchImageSources, SAUCENAO_API } from './service';
|
|
4
|
+
import { createMarkdown } from './util';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
* SauceNAO JSON API 搜索响应。
|
|
7
|
-
*
|
|
8
|
-
* @see https://saucenao.com/user.php?page=search-api
|
|
9
|
-
*/
|
|
10
|
-
interface SauceNao {
|
|
11
|
-
/** 搜索请求、配额和索引状态。 */
|
|
12
|
-
readonly header: {
|
|
13
|
-
/** SauceNAO 用户 ID。 */
|
|
14
|
-
readonly user_id: string;
|
|
15
|
-
/** 账户类型。 */
|
|
16
|
-
readonly account_type: string;
|
|
17
|
-
/** 30 秒内允许的请求数量。 */
|
|
18
|
-
readonly short_limit: string;
|
|
19
|
-
/** 24 小时内允许的请求数量。 */
|
|
20
|
-
readonly long_limit: string;
|
|
21
|
-
/** 24 小时内剩余的请求数量。 */
|
|
22
|
-
readonly long_remaining: number;
|
|
23
|
-
/** 30 秒内剩余的请求数量。 */
|
|
24
|
-
readonly short_remaining: number;
|
|
25
|
-
/** 请求状态,`0` 表示成功。 */
|
|
26
|
-
readonly status: number;
|
|
27
|
-
/** 请求失败时返回的错误信息。 */
|
|
28
|
-
readonly message?: string;
|
|
29
|
-
/** `numres` 请求的结果数量。 */
|
|
30
|
-
readonly results_requested: string;
|
|
31
|
-
/** 参与搜索的索引状态,以索引编号为键。 */
|
|
32
|
-
readonly index: Readonly<
|
|
33
|
-
Record<
|
|
34
|
-
string,
|
|
35
|
-
{
|
|
36
|
-
/** 索引状态。 */
|
|
37
|
-
readonly status: number;
|
|
38
|
-
/** 父索引编号。 */
|
|
39
|
-
readonly parent_id: number;
|
|
40
|
-
/** 索引编号。 */
|
|
41
|
-
readonly id: number;
|
|
42
|
-
/** 该索引匹配到的结果数量。 */
|
|
43
|
-
readonly results: number;
|
|
44
|
-
}
|
|
45
|
-
>
|
|
46
|
-
>;
|
|
47
|
-
/** 本次搜索的深度。 */
|
|
48
|
-
readonly search_depth: string;
|
|
49
|
-
/** 返回结果中的最低相似度。 */
|
|
50
|
-
readonly minimum_similarity: number;
|
|
51
|
-
/** 用于显示查询图片的路径。 */
|
|
52
|
-
readonly query_image_display: string;
|
|
53
|
-
/** 查询图片的文件名。 */
|
|
54
|
-
readonly query_image: string;
|
|
55
|
-
/** 实际返回的结果数量。 */
|
|
56
|
-
readonly results_returned: number;
|
|
57
|
-
};
|
|
58
|
-
/** 匹配到的图片来源。 */
|
|
59
|
-
readonly results: readonly ImageSource[];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** SauceNAO 返回的一条图片来源。 */
|
|
63
|
-
interface ImageSource {
|
|
64
|
-
/** 匹配结果信息。 */
|
|
65
|
-
readonly header: {
|
|
66
|
-
/** 相似度百分比。 */
|
|
67
|
-
readonly similarity: string;
|
|
68
|
-
/** 缩略图地址。 */
|
|
69
|
-
readonly thumbnail: string;
|
|
70
|
-
/** 搜索索引编号。 */
|
|
71
|
-
readonly index_id: number;
|
|
72
|
-
/** 搜索索引名称。 */
|
|
73
|
-
readonly index_name: string;
|
|
74
|
-
/** 重复结果数量。 */
|
|
75
|
-
readonly dupes: number;
|
|
76
|
-
/** 隐藏状态。 */
|
|
77
|
-
readonly hidden: number;
|
|
78
|
-
};
|
|
79
|
-
/** 来源数据。字段由 `header.index_id` 对应的搜索索引决定。 */
|
|
80
|
-
readonly data: {
|
|
81
|
-
/** 搜索索引可能返回的其他专有字段。 */
|
|
82
|
-
readonly [key: string]: unknown;
|
|
83
|
-
/** 来源页面地址。 */
|
|
84
|
-
readonly ext_urls?: readonly string[];
|
|
85
|
-
/** 作品标题。 */
|
|
86
|
-
readonly title?: string;
|
|
87
|
-
/** 作品或系列名称。 */
|
|
88
|
-
readonly material?: string;
|
|
89
|
-
/** 日文名称。 */
|
|
90
|
-
readonly jp_name?: string;
|
|
91
|
-
/** 英文名称。 */
|
|
92
|
-
readonly eng_name?: string;
|
|
93
|
-
/** 原始来源。 */
|
|
94
|
-
readonly source?: string;
|
|
95
|
-
/** 创作者名称。 */
|
|
96
|
-
readonly creator?: string | readonly string[];
|
|
97
|
-
/** 角色名称。 */
|
|
98
|
-
readonly characters?: string;
|
|
99
|
-
/** 发布时间。 */
|
|
100
|
-
readonly created_at?: string;
|
|
101
|
-
/** Danbooru 作品 ID。 */
|
|
102
|
-
readonly danbooru_id?: number;
|
|
103
|
-
/** e621 作品 ID。 */
|
|
104
|
-
readonly e621_id?: number;
|
|
105
|
-
/** Gelbooru 作品 ID。 */
|
|
106
|
-
readonly gelbooru_id?: number;
|
|
107
|
-
/** Pixiv 作者 ID。 */
|
|
108
|
-
readonly member_id?: number;
|
|
109
|
-
/** Pixiv 作者名称。 */
|
|
110
|
-
readonly member_name?: string;
|
|
111
|
-
/** Pixiv 作品 ID。 */
|
|
112
|
-
readonly pixiv_id?: number;
|
|
113
|
-
/** 推文 ID。 */
|
|
114
|
-
readonly tweet_id?: string;
|
|
115
|
-
/** Twitter 用户名。 */
|
|
116
|
-
readonly twitter_user_handle?: string;
|
|
117
|
-
/** Twitter 用户 ID。 */
|
|
118
|
-
readonly twitter_user_id?: string;
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const { SAUCENAO_API_KEY, SAUCENAO_NUMRES = 3, SAUCENAO_SIMILARITY_THRESHOLD = 50 } = import.meta.env;
|
|
123
|
-
const SAUCENAO_API = new URL('https://saucenao.com/search.php');
|
|
124
|
-
const LOW_SIMILARITY_THUMBNAIL = 'https://kokkoro.js.org/saucenao-low-similarity.jpg';
|
|
125
|
-
const similarityThreshold = Number(SAUCENAO_SIMILARITY_THRESHOLD);
|
|
126
|
-
|
|
127
|
-
function getTitle(data: ImageSource['data']) {
|
|
128
|
-
const { eng_name: engName, jp_name: jpName, material, source, title } = data;
|
|
129
|
-
return title || material || jpName || engName || source;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function getPlatform(indexName: string) {
|
|
133
|
-
const [, platform] = indexName.match(/^Index #\d+:\s*(.*?)(?:\s+-\s+.*)?$/) ?? [];
|
|
134
|
-
return platform ?? indexName;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function getUrl(data: ImageSource['data']) {
|
|
138
|
-
const [url] = data.ext_urls ?? [];
|
|
139
|
-
|
|
140
|
-
if (url) {
|
|
141
|
-
return url;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (data.source && URL.canParse(data.source)) {
|
|
145
|
-
return data.source;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function searchImage(image: string) {
|
|
150
|
-
if (!SAUCENAO_API_KEY) {
|
|
151
|
-
throw new Error('未配置 SAUCENAO_API_KEY 环境变量');
|
|
152
|
-
}
|
|
153
|
-
const form = new FormData();
|
|
154
|
-
|
|
155
|
-
form.set('api_key', SAUCENAO_API_KEY);
|
|
156
|
-
form.set('output_type', '2');
|
|
157
|
-
form.set('numres', String(SAUCENAO_NUMRES));
|
|
158
|
-
form.set('db', '999');
|
|
159
|
-
form.set('url', image);
|
|
160
|
-
|
|
161
|
-
const response = await fetch(SAUCENAO_API, { method: 'POST', body: form });
|
|
162
|
-
|
|
163
|
-
if (!response.ok) {
|
|
164
|
-
throw new Error(`接口请求失败,状态码 ${response.status}`);
|
|
165
|
-
}
|
|
166
|
-
const { header, results } = <SauceNao>await response.json();
|
|
167
|
-
|
|
168
|
-
if (header.status !== 0) {
|
|
169
|
-
throw new Error(header.message ?? `SauceNAO 搜索失败,状态码 ${header.status}`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (results.length === 0) {
|
|
173
|
-
throw new Error('没有找到图片来源');
|
|
174
|
-
}
|
|
175
|
-
return results;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function createMessage(results: readonly ImageSource[]) {
|
|
179
|
-
const content = await Promise.all(
|
|
180
|
-
results.map(async ({ data, header }, index) => {
|
|
181
|
-
const { index_name: indexName, similarity, thumbnail } = header;
|
|
182
|
-
const platform = getPlatform(indexName);
|
|
183
|
-
const title = getTitle(data) || platform;
|
|
184
|
-
const url = getUrl(data);
|
|
185
|
-
const lines = ['### 标题', `- ${title}`, '### 平台', `- ${platform}`, '### 相似度', `- ${similarity}%`];
|
|
186
|
-
|
|
187
|
-
if (thumbnail) {
|
|
188
|
-
if (Number(similarity) < similarityThreshold) {
|
|
189
|
-
lines.push('### 缩略图', ``);
|
|
190
|
-
} else {
|
|
191
|
-
const response = await fetch(thumbnail);
|
|
192
|
-
|
|
193
|
-
if (!response.ok) {
|
|
194
|
-
throw new Error(`缩略图请求失败,状态码 ${response.status}`);
|
|
195
|
-
}
|
|
196
|
-
const { height, width } = await new Image(await response.blob()).metadata();
|
|
197
|
-
|
|
198
|
-
lines.push('### 缩略图', ``);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (url) {
|
|
203
|
-
lines.push(`[查看来源](${url})`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (index < results.length - 1) {
|
|
207
|
-
lines.push('***');
|
|
208
|
-
}
|
|
209
|
-
return lines.join('\n');
|
|
210
|
-
}),
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
msg_type: 2,
|
|
215
|
-
markdown: {
|
|
216
|
-
content: ['## SauceNAO 搜图结果', '***', ...content].join('\n\n'),
|
|
217
|
-
},
|
|
218
|
-
} satisfies CommandReply;
|
|
219
|
-
}
|
|
6
|
+
const logger = useLogger();
|
|
220
7
|
|
|
221
8
|
export default () => {
|
|
222
9
|
useCommand('/搜图', async context => {
|
|
@@ -225,6 +12,23 @@ export default () => {
|
|
|
225
12
|
if (!image?.url) {
|
|
226
13
|
throw new Error('请在指令中附带需要搜索的图片');
|
|
227
14
|
}
|
|
228
|
-
|
|
15
|
+
logger.debug('发送 SauceNAO 请求', {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
url: SAUCENAO_API,
|
|
18
|
+
payload: { url: image.url },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = await fetchImageSources(image.url);
|
|
22
|
+
const { results } = result;
|
|
23
|
+
|
|
24
|
+
logger.debug('收到 SauceNAO 响应', result);
|
|
25
|
+
logger.info('已找到图片来源', { count: results.length });
|
|
26
|
+
|
|
27
|
+
await context.reply({
|
|
28
|
+
msg_type: 2,
|
|
29
|
+
markdown: {
|
|
30
|
+
content: await createMarkdown(results),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
229
33
|
}).shortcut('搜图');
|
|
230
34
|
};
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SauceNAO JSON API 搜索响应。
|
|
3
|
+
*
|
|
4
|
+
* @see https://saucenao.com/user.php?page=search-api
|
|
5
|
+
*/
|
|
6
|
+
export interface SauceNao {
|
|
7
|
+
/** 搜索请求、配额和索引状态。 */
|
|
8
|
+
readonly header: {
|
|
9
|
+
/** SauceNAO 用户 ID。 */
|
|
10
|
+
readonly user_id: string;
|
|
11
|
+
/** 账户类型。 */
|
|
12
|
+
readonly account_type: string;
|
|
13
|
+
/** 30 秒内允许的请求数量。 */
|
|
14
|
+
readonly short_limit: string;
|
|
15
|
+
/** 24 小时内允许的请求数量。 */
|
|
16
|
+
readonly long_limit: string;
|
|
17
|
+
/** 24 小时内剩余的请求数量。 */
|
|
18
|
+
readonly long_remaining: number;
|
|
19
|
+
/** 30 秒内剩余的请求数量。 */
|
|
20
|
+
readonly short_remaining: number;
|
|
21
|
+
/** 请求状态,`0` 表示成功。 */
|
|
22
|
+
readonly status: number;
|
|
23
|
+
/** 请求失败时返回的错误信息。 */
|
|
24
|
+
readonly message?: string;
|
|
25
|
+
/** `numres` 请求的结果数量。 */
|
|
26
|
+
readonly results_requested: string;
|
|
27
|
+
/** 参与搜索的索引状态,以索引编号为键。 */
|
|
28
|
+
readonly index: Readonly<
|
|
29
|
+
Record<
|
|
30
|
+
string,
|
|
31
|
+
{
|
|
32
|
+
/** 索引状态。 */
|
|
33
|
+
readonly status: number;
|
|
34
|
+
/** 父索引编号。 */
|
|
35
|
+
readonly parent_id: number;
|
|
36
|
+
/** 索引编号。 */
|
|
37
|
+
readonly id: number;
|
|
38
|
+
/** 该索引匹配到的结果数量。 */
|
|
39
|
+
readonly results: number;
|
|
40
|
+
}
|
|
41
|
+
>
|
|
42
|
+
>;
|
|
43
|
+
/** 本次搜索的深度。 */
|
|
44
|
+
readonly search_depth: string;
|
|
45
|
+
/** 返回结果中的最低相似度。 */
|
|
46
|
+
readonly minimum_similarity: number;
|
|
47
|
+
/** 用于显示查询图片的路径。 */
|
|
48
|
+
readonly query_image_display: string;
|
|
49
|
+
/** 查询图片的文件名。 */
|
|
50
|
+
readonly query_image: string;
|
|
51
|
+
/** 实际返回的结果数量。 */
|
|
52
|
+
readonly results_returned: number;
|
|
53
|
+
};
|
|
54
|
+
/** 匹配到的图片来源。 */
|
|
55
|
+
readonly results: readonly ImageSource[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** SauceNAO 返回的一条图片来源。 */
|
|
59
|
+
export interface ImageSource {
|
|
60
|
+
/** 匹配结果信息。 */
|
|
61
|
+
readonly header: {
|
|
62
|
+
/** 相似度百分比。 */
|
|
63
|
+
readonly similarity: string;
|
|
64
|
+
/** 缩略图地址。 */
|
|
65
|
+
readonly thumbnail: string;
|
|
66
|
+
/** 搜索索引编号。 */
|
|
67
|
+
readonly index_id: number;
|
|
68
|
+
/** 搜索索引名称。 */
|
|
69
|
+
readonly index_name: string;
|
|
70
|
+
/** 重复结果数量。 */
|
|
71
|
+
readonly dupes: number;
|
|
72
|
+
/** 隐藏状态。 */
|
|
73
|
+
readonly hidden: number;
|
|
74
|
+
};
|
|
75
|
+
/** 来源数据。字段由 `header.index_id` 对应的搜索索引决定。 */
|
|
76
|
+
readonly data: {
|
|
77
|
+
/** 搜索索引可能返回的其他专有字段。 */
|
|
78
|
+
readonly [key: string]: unknown;
|
|
79
|
+
/** 来源页面地址。 */
|
|
80
|
+
readonly ext_urls?: readonly string[];
|
|
81
|
+
/** 作品标题。 */
|
|
82
|
+
readonly title?: string;
|
|
83
|
+
/** 作品或系列名称。 */
|
|
84
|
+
readonly material?: string;
|
|
85
|
+
/** 日文名称。 */
|
|
86
|
+
readonly jp_name?: string;
|
|
87
|
+
/** 英文名称。 */
|
|
88
|
+
readonly eng_name?: string;
|
|
89
|
+
/** 原始来源。 */
|
|
90
|
+
readonly source?: string;
|
|
91
|
+
/** 创作者名称。 */
|
|
92
|
+
readonly creator?: string | readonly string[];
|
|
93
|
+
/** 角色名称。 */
|
|
94
|
+
readonly characters?: string;
|
|
95
|
+
/** 发布时间。 */
|
|
96
|
+
readonly created_at?: string;
|
|
97
|
+
/** Danbooru 作品 ID。 */
|
|
98
|
+
readonly danbooru_id?: number;
|
|
99
|
+
/** e621 作品 ID。 */
|
|
100
|
+
readonly e621_id?: number;
|
|
101
|
+
/** Gelbooru 作品 ID。 */
|
|
102
|
+
readonly gelbooru_id?: number;
|
|
103
|
+
/** Pixiv 作者 ID。 */
|
|
104
|
+
readonly member_id?: number;
|
|
105
|
+
/** Pixiv 作者名称。 */
|
|
106
|
+
readonly member_name?: string;
|
|
107
|
+
/** Pixiv 作品 ID。 */
|
|
108
|
+
readonly pixiv_id?: number;
|
|
109
|
+
/** 推文 ID。 */
|
|
110
|
+
readonly tweet_id?: string;
|
|
111
|
+
/** Twitter 用户名。 */
|
|
112
|
+
readonly twitter_user_handle?: string;
|
|
113
|
+
/** Twitter 用户 ID。 */
|
|
114
|
+
readonly twitter_user_id?: string;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { SAUCENAO_API_KEY, SAUCENAO_NUMRES = 3 } = import.meta.env;
|
|
119
|
+
/** SauceNAO JSON API 的请求地址。 */
|
|
120
|
+
export const SAUCENAO_API = 'https://saucenao.com/search.php';
|
|
121
|
+
|
|
122
|
+
/** 使用 SauceNAO 搜索图片来源,并返回完整的接口响应。 */
|
|
123
|
+
export async function fetchImageSources(image: string): Promise<SauceNao> {
|
|
124
|
+
if (!SAUCENAO_API_KEY) {
|
|
125
|
+
throw new Error('未配置 SAUCENAO_API_KEY 环境变量');
|
|
126
|
+
}
|
|
127
|
+
const form = new FormData();
|
|
128
|
+
|
|
129
|
+
form.set('api_key', SAUCENAO_API_KEY);
|
|
130
|
+
form.set('output_type', '2');
|
|
131
|
+
form.set('numres', String(SAUCENAO_NUMRES));
|
|
132
|
+
form.set('db', '999');
|
|
133
|
+
form.set('url', image);
|
|
134
|
+
|
|
135
|
+
const response = await fetch(SAUCENAO_API, { method: 'POST', body: form });
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(`接口请求失败,状态码 ${response.status}`);
|
|
139
|
+
}
|
|
140
|
+
const result = <SauceNao>await response.json();
|
|
141
|
+
const { header, results } = result;
|
|
142
|
+
|
|
143
|
+
if (header.status !== 0) {
|
|
144
|
+
throw new Error(header.message ?? `SauceNAO 搜索失败,状态码 ${header.status}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (results.length === 0) {
|
|
148
|
+
throw new Error('没有找到图片来源');
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Image } from 'bun';
|
|
2
|
+
|
|
3
|
+
import { type ImageSource } from './service';
|
|
4
|
+
|
|
5
|
+
const { SAUCENAO_SIMILARITY_THRESHOLD = 50 } = import.meta.env;
|
|
6
|
+
const LOW_SIMILARITY_THUMBNAIL = 'https://kokkoro.js.org/saucenao-low-similarity.jpg';
|
|
7
|
+
const similarityThreshold = Number(SAUCENAO_SIMILARITY_THRESHOLD);
|
|
8
|
+
|
|
9
|
+
function getTitle(data: ImageSource['data']) {
|
|
10
|
+
const { eng_name: engName, jp_name: jpName, material, source, title } = data;
|
|
11
|
+
return title || material || jpName || engName || source;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPlatform(indexName: string) {
|
|
15
|
+
const [, platform] = indexName.match(/^Index #\d+:\s*(.*?)(?:\s+-\s+.*)?$/) ?? [];
|
|
16
|
+
return platform ?? indexName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getUrl(data: ImageSource['data']) {
|
|
20
|
+
const [url] = data.ext_urls ?? [];
|
|
21
|
+
|
|
22
|
+
if (url) {
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (data.source && URL.canParse(data.source)) {
|
|
27
|
+
return data.source;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 将 SauceNAO 搜索结果转换为 QQ Markdown。 */
|
|
32
|
+
export async function createMarkdown(results: readonly ImageSource[]): Promise<string> {
|
|
33
|
+
const content = await Promise.all(
|
|
34
|
+
results.map(async ({ data, header }, index) => {
|
|
35
|
+
const { index_name: indexName, similarity, thumbnail } = header;
|
|
36
|
+
const platform = getPlatform(indexName);
|
|
37
|
+
const title = getTitle(data) || platform;
|
|
38
|
+
const url = getUrl(data);
|
|
39
|
+
const lines = ['### 标题', `- ${title}`, '### 平台', `- ${platform}`, '### 相似度', `- ${similarity}%`];
|
|
40
|
+
|
|
41
|
+
if (thumbnail) {
|
|
42
|
+
if (Number(similarity) < similarityThreshold) {
|
|
43
|
+
lines.push('### 缩略图', ``);
|
|
44
|
+
} else {
|
|
45
|
+
const response = await fetch(thumbnail);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`缩略图请求失败,状态码 ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
const { height, width } = await new Image(await response.blob()).metadata();
|
|
51
|
+
|
|
52
|
+
lines.push('### 缩略图', ``);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (url) {
|
|
57
|
+
lines.push(`[查看来源](${url})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (index < results.length - 1) {
|
|
61
|
+
lines.push('***');
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return ['## SauceNAO 搜图结果', '***', ...content].join('\n\n');
|
|
68
|
+
}
|