Skip to content

Commit cbfb751

Browse files
committed
refa: migrate most small booru to subpackages of core
1 parent bd17daf commit cbfb751

File tree

34 files changed

+749
-667
lines changed

34 files changed

+749
-667
lines changed

packages/core/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,48 @@
88
"lib",
99
"dist"
1010
],
11+
"exports": {
12+
"./danbooru": {
13+
"types": "./lib/sources/danbooru/index.d.ts",
14+
"require": "./lib/sources/danbooru/index.js",
15+
"default": "./lib/sources/danbooru/index.js"
16+
},
17+
"./e621": {
18+
"types": "./lib/sources/danbooru/index.d.ts",
19+
"require": "./lib/sources/e621/index.js",
20+
"default": "./lib/sources/e621/index.js"
21+
},
22+
"./gelbooru": {
23+
"types": "./lib/sources/danbooru/index.d.ts",
24+
"require": "./lib/sources/gelbooru/index.js",
25+
"default": "./lib/sources/gelbooru/index.js"
26+
},
27+
"./konachan": {
28+
"types": "./lib/sources/danbooru/index.d.ts",
29+
"require": "./lib/sources/konachan/index.js",
30+
"default": "./lib/sources/konachan/index.js"
31+
},
32+
"./lolibooru": {
33+
"types": "./lib/sources/danbooru/index.d.ts",
34+
"require": "./lib/sources/lolibooru/index.js",
35+
"default": "./lib/sources/lolibooru/index.js"
36+
},
37+
"./safebooru": {
38+
"types": "./lib/sources/danbooru/index.d.ts",
39+
"require": "./lib/sources/safebooru/index.js",
40+
"default": "./lib/sources/safebooru/index.js"
41+
},
42+
"./sankaku": {
43+
"types": "./lib/sources/danbooru/index.d.ts",
44+
"require": "./lib/sources/sankaku/index.js",
45+
"default": "./lib/sources/sankaku/index.js"
46+
},
47+
"./yande": {
48+
"types": "./lib/sources/danbooru/index.d.ts",
49+
"require": "./lib/sources/yande/index.js",
50+
"default": "./lib/sources/yande/index.js"
51+
}
52+
},
1153
"author": "Shigma <shigma10826@gmail.com>",
1254
"license": "MIT",
1355
"repository": {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Context, Schema, trimSlash } from 'koishi'
2+
import { ImageSource } from '../../source'
3+
import { Danbooru } from './types'
4+
5+
class DanbooruImageSource extends ImageSource<DanbooruImageSource.Config> {
6+
languages = ['en']
7+
source = 'danbooru'
8+
9+
constructor(ctx: Context, config: DanbooruImageSource.Config) {
10+
super(ctx, config)
11+
}
12+
13+
get keyPair() {
14+
if (!this.config.keyPairs.length) return
15+
return this.config.keyPairs[Math.floor(Math.random() * this.config.keyPairs.length)]
16+
}
17+
18+
async get(query: ImageSource.Query): Promise<ImageSource.Result[]> {
19+
const keyPair = this.keyPair
20+
const data = await this.http.get<Danbooru.Post[]>(trimSlash(this.config.endpoint) + '/posts.json', {
21+
params: {
22+
tags: query.tags.join(' '),
23+
random: true,
24+
limit: query.count,
25+
...(keyPair ? { login: keyPair.login, api_key: keyPair.apiKey } : {}),
26+
},
27+
})
28+
29+
if (!Array.isArray(data)) {
30+
return
31+
}
32+
33+
return data.map((post) => {
34+
return {
35+
url: post.file_url,
36+
pageUrl: post.source,
37+
author: post.tag_string_artist.replace(/ /g, ', ').replace(/_/g, ' '),
38+
tags: post.tag_string.split(' ').map((t) => t.replace(/_/g, ' ')),
39+
nsfw: post.rating === 'e' || post.rating === 'q',
40+
}
41+
})
42+
}
43+
}
44+
45+
namespace DanbooruImageSource {
46+
export interface Config extends ImageSource.Config {
47+
endpoint: string
48+
keyPairs: { login: string; apiKey: string }[]
49+
}
50+
51+
export const Config: Schema<Config> = Schema.intersect([
52+
ImageSource.createSchema({ label: 'danbooru' }),
53+
Schema.object({
54+
endpoint: Schema.string().description('Danbooru 的 URL。').default('https://danbooru.donmai.us/'),
55+
/**
56+
* @see https://danbooru.donmai.us/wiki_pages/help%3Aapi
57+
*/
58+
keyPairs: Schema.array(
59+
Schema.object({
60+
login: Schema.string().required().description('用户名。'),
61+
apiKey: Schema.string().required().role('secret').description('API 密钥。'),
62+
}),
63+
).description(
64+
'API 密钥对。[点击前往获取及设置教程](https://booru.koishi.chat/zh-CN/plugins/danbooru.html#获取与设置登录凭据)',
65+
),
66+
}).description('搜索设置'),
67+
])
68+
}
69+
70+
export default DanbooruImageSource
File renamed without changes.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Context, Quester, Schema, trimSlash } from 'koishi'
2+
import { ImageSource } from '../../source'
3+
import { e621 } from './types'
4+
5+
class e621ImageSource extends ImageSource<e621ImageSource.Config> {
6+
languages = ['en']
7+
source = 'e621'
8+
http: Quester
9+
10+
constructor(ctx: Context, config: e621ImageSource.Config) {
11+
super(ctx, config)
12+
this.http = this.http.extend({
13+
headers: {
14+
'User-Agent': config.userAgent,
15+
},
16+
})
17+
}
18+
19+
get keyPair() {
20+
if (!this.config.keyPairs.length) return
21+
return this.config.keyPairs[Math.floor(Math.random() * this.config.keyPairs.length)]
22+
}
23+
24+
async get(query: ImageSource.Query): Promise<ImageSource.Result[]> {
25+
if (!query.tags.find((t) => t.startsWith('order:'))) query.tags.push('order:random')
26+
const keyPair = this.keyPair
27+
const data = await this.http.get<{
28+
posts: e621.Post[]
29+
}>(trimSlash(this.config.endpoint) + '/posts.json', {
30+
params: {
31+
tags: query.tags.join(' '),
32+
limit: query.count,
33+
},
34+
headers: keyPair
35+
? { Authorization: 'Basic ' + Buffer.from(`${keyPair.login}:${keyPair.apiKey}`).toString('base64') }
36+
: {},
37+
})
38+
39+
if (!Array.isArray(data.posts)) {
40+
return
41+
}
42+
43+
return data.posts.map((post) => {
44+
return {
45+
url: post.file.url,
46+
pageUrl: trimSlash(this.config.endpoint) + `/post/${post.id}`,
47+
author: post.tags.artist.join(', '),
48+
tags: Object.values(post.tags).flat(),
49+
nsfw: post.rating !== 's',
50+
desc: post.description,
51+
}
52+
})
53+
}
54+
}
55+
56+
namespace e621ImageSource {
57+
export interface Config extends ImageSource.Config {
58+
endpoint: string
59+
keyPairs: { login: string; apiKey: string }[]
60+
userAgent: string
61+
}
62+
63+
export const Config: Schema<Config> = Schema.intersect([
64+
ImageSource.createSchema({ label: 'e621' }),
65+
Schema.object({
66+
endpoint: Schema.string().description('e621/e926 的 URL。').default('https://e621.net/'),
67+
keyPairs: Schema.array(
68+
Schema.object({
69+
login: Schema.string().required().description('e621/e926 的用户名。'),
70+
apiKey: Schema.string().required().role('secret').description('e621/e926 的 API Key。'),
71+
}),
72+
)
73+
.default([])
74+
.description('e621/e926 的登录凭据。'),
75+
userAgent: Schema.string()
76+
.description('设置请求的 User Agent。')
77+
.default(
78+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.37',
79+
),
80+
}).description('搜索设置'),
81+
])
82+
}
83+
84+
export default e621ImageSource
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Context, Schema, trimSlash } from 'koishi'
2+
import { ImageSource } from '../../source'
3+
import { Gelbooru } from './types'
4+
5+
class GelbooruImageSource extends ImageSource<GelbooruImageSource.Config> {
6+
languages = ['en']
7+
source = 'gelbooru'
8+
9+
constructor(ctx: Context, config: GelbooruImageSource.Config) {
10+
super(ctx, config)
11+
}
12+
13+
get keyPair() {
14+
if (!this.config.keyPairs.length) return
15+
return this.config.keyPairs[Math.floor(Math.random() * this.config.keyPairs.length)]
16+
}
17+
18+
async get(query: ImageSource.Query): Promise<ImageSource.Result[]> {
19+
// API docs: https://gelbooru.com/index.php?page=help&topic=dapi
20+
const params = {
21+
tags: query.tags.join('+') + '+sort:random',
22+
page: 'dapi',
23+
s: 'post',
24+
q: 'index',
25+
json: 1,
26+
limit: query.count,
27+
}
28+
let url =
29+
trimSlash(this.config.endpoint) +
30+
'?' +
31+
Object.entries(params)
32+
.map(([key, value]) => `${key}=${value}`)
33+
.join('&')
34+
35+
const keyPair = this.keyPair
36+
if (keyPair) {
37+
// The keyPair from Gelbooru is already url-encoded.
38+
url += keyPair
39+
}
40+
41+
const data = await this.http.get<Gelbooru.Response>(url)
42+
43+
if (!Array.isArray(data.post)) {
44+
return
45+
}
46+
47+
return data.post.map((post) => {
48+
return {
49+
url: post.file_url,
50+
pageUrl: post.source,
51+
author: post.owner.replace(/ /g, ', ').replace(/_/g, ' '),
52+
tags: post.tags.split(' ').map((t) => t.replace(/_/g, ' ')),
53+
nsfw: ['explicit', 'questionable'].includes(post.rating),
54+
}
55+
})
56+
}
57+
}
58+
59+
namespace GelbooruImageSource {
60+
export interface Config extends ImageSource.Config {
61+
endpoint: string
62+
keyPairs: string[]
63+
}
64+
65+
export const Config: Schema<Config> = Schema.intersect([
66+
ImageSource.createSchema({ label: 'gelbooru' }),
67+
Schema.object({
68+
endpoint: Schema.string().description('Gelbooru 的 URL。').default('https://gelbooru.com/index.php'),
69+
keyPairs: Schema.array(Schema.string().required().role('secret'))
70+
.description('Gelbooru 的登录凭据。')
71+
.default([]),
72+
}).description('搜索设置'),
73+
])
74+
}
75+
76+
export default GelbooruImageSource
File renamed without changes.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { createHash } from 'node:crypto'
2+
import { Context, Dict, Schema, trimSlash } from 'koishi'
3+
import { ImageSource } from '../../source'
4+
import { Konachan } from './types'
5+
6+
/**
7+
* Konachan requires a password hash for authentication.
8+
*
9+
* @see https://konachan.net/help/api
10+
*/
11+
function hashPassword(password: string) {
12+
const salted = `So-I-Heard-You-Like-Mupkids-?--${password}--`
13+
// do a SHA1 hash of the salted password
14+
const hash = createHash('sha1')
15+
hash.update(salted)
16+
return hash.digest('hex')
17+
}
18+
19+
class KonachanImageSource extends ImageSource<KonachanImageSource.Config> {
20+
languages = ['en']
21+
source = 'konachan'
22+
23+
constructor(ctx: Context, config: KonachanImageSource.Config) {
24+
super(ctx, config)
25+
}
26+
27+
get keyPair() {
28+
if (!this.config.keyPairs.length) return
29+
const key = this.config.keyPairs[Math.floor(Math.random() * this.config.keyPairs.length)]
30+
return {
31+
login: key.login,
32+
password_hash: hashPassword(key.password),
33+
}
34+
}
35+
36+
async get(query: ImageSource.Query): Promise<ImageSource.Result[]> {
37+
// API docs: https://konachan.net/help/api and https://konachan.com/help/api
38+
const params: Dict<string> = {
39+
tags: query.tags.join('+') + '+order:random',
40+
limit: `${query.count}`,
41+
}
42+
let url = trimSlash(this.config.endpoint) + '/post.json'
43+
44+
const keyPair = this.keyPair
45+
if (keyPair) {
46+
params['login'] = keyPair.login
47+
params['password_hash'] = keyPair.password_hash
48+
}
49+
const data = await this.http.get<Konachan.Response[]>(url, { params: new URLSearchParams(params) })
50+
51+
if (!Array.isArray(data)) {
52+
return
53+
}
54+
55+
return data.map((post) => {
56+
return {
57+
url: post.file_url,
58+
pageUrl: post.source,
59+
author: post.author.replace(/ /g, ', ').replace(/_/g, ' '),
60+
tags: post.tags.split(' ').map((t) => t.replace(/_/g, ' ')),
61+
nsfw: ['e', 'q'].includes(post.rating),
62+
}
63+
})
64+
}
65+
}
66+
67+
namespace KonachanImageSource {
68+
export interface Config extends ImageSource.Config {
69+
endpoint: string
70+
keyPairs: { login: string; password: string }[]
71+
}
72+
73+
export const Config: Schema<Config> = Schema.intersect([
74+
ImageSource.createSchema({ label: 'konachan' }),
75+
Schema.object({
76+
endpoint: Schema.union([
77+
Schema.const('https://konachan.com/').description('Konachan.com (NSFW)'),
78+
Schema.const('https://konachan.net/').description('Konachan.net (SFW)'),
79+
])
80+
.description('Konachan 的 URL。')
81+
.default('https://konachan.com/'),
82+
keyPairs: Schema.array(
83+
Schema.object({
84+
login: Schema.string().required().description('用户名'),
85+
password: Schema.string().required().role('secret').description('密码'),
86+
}),
87+
).description('Konachan 的登录凭据。'),
88+
}).description('搜索设置'),
89+
])
90+
}
91+
92+
export default KonachanImageSource
File renamed without changes.

0 commit comments

Comments
 (0)