diff --git a/lib/config.ts b/lib/config.ts index 2afbce8a7..735e9614b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -164,6 +164,10 @@ export type Config = { game4399: { cookie?: string; }; + gelbooru: { + apiKey?: string; + userId?: string; + }; github: { access_token?: string; }; @@ -597,6 +601,10 @@ const calculateValue = () => { game4399: { cookie: envs.GAME_4399, }, + gelbooru: { + apiKey: envs.GELBOORU_API_KEY, + userId: envs.GELBOORU_USER_ID, + }, github: { access_token: envs.GITHUB_ACCESS_TOKEN, }, @@ -610,7 +618,6 @@ const calculateValue = () => { cookies: envs.GUOZAOKE_COOKIES, }, hefeng: { - // weather key: envs.HEFENG_KEY, }, infzm: { diff --git a/lib/routes/gelbooru/namespace.ts b/lib/routes/gelbooru/namespace.ts new file mode 100644 index 000000000..fd9f5d0d6 --- /dev/null +++ b/lib/routes/gelbooru/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Gelbooru', + url: 'gelbooru.com', + description: 'gelbooru posts', +}; diff --git a/lib/routes/gelbooru/post.ts b/lib/routes/gelbooru/post.ts new file mode 100644 index 000000000..83ddce77a --- /dev/null +++ b/lib/routes/gelbooru/post.ts @@ -0,0 +1,113 @@ +import { Route, ViewType } from '@/types'; +import { Context } from 'hono'; +import { parseDate } from '@/utils/parse-date'; +import { renderDesc, getAPIKeys } from './utils'; + +import got from '@/utils/got'; +import queryString from 'query-string'; + +export const route: Route = { + path: '/post/:tags?/:quality?', + categories: ['picture'], + view: ViewType.Pictures, + example: '/gelbooru/post/1girl rating:general', + parameters: { + tags: '要搜索的标签,多个标签用 ` `(空格)隔开', + quality: { + description: '图片质量,可选值为 `sample`(压缩后的图片,推荐值) 或 `orig`(原图),默认为 `sample`', + default: 'sample', + }, + }, + features: { + requireConfig: [ + { + name: 'GELBOORU_API_KEY', + description: 'Gelbooru 偶尔会开启 API 认证,需配合 `GELBOORU_USER_ID`,从 `https://gelbooru.com/index.php?page=account&s=options` 获取', + optional: true, + }, + { + name: 'GELBOORU_USER_ID', + description: '参见 `GELBOORU_API_KEY`', + optional: true, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['gelbooru.com/index.php'], + }, + ], + name: '标签查询', + maintainers: ['magicFeirl'], + description: ` +- 默认查询: \`/gelbooru/post\` 功能等同查询 Gelbooru 网站最新的投稿 +- 单标签查询: \`/gelbooru/post/1girl\` 查询 \`1girl\` 的最新投稿 +- 多标签查询: \`/gelbooru/post/1girl school_uniform rating:general\` +- 指定为原图: \`/gelbooru/post/1girl school_uniform rating:general/orig\` +- 更多例子: 请参考 Gelbooru 官方 wiki https://gelbooru.com/index.php?page=wiki&s=&s=view&id=25921 + +**可选的 URL 参数** +- limit 页面返回数据量,默认 40,可选 1 ~ 100 + +e.g.: \`/gelbooru/post?limit=20&\` +`, + handler, +}; + +async function handler(ctx: Context) { + const { tags: _tags = '', quality = 'sample' }: { tags?: string; quality?: 'sample' | 'orig' } = ctx.req.param(); + + const tags = decodeURIComponent(_tags).trim(); + + const { limit = 40 }: { limit?: number } = ctx.req.query(); + const { apiKey, useId } = getAPIKeys(); + + const response = await got({ + url: 'https://gelbooru.com/index.php', + searchParams: queryString.stringify({ + page: 'dapi', + s: 'post', + q: 'index', + tags, + api_key: apiKey, + user_id: useId, + limit: limit <= 0 || limit > 100 ? 40 : limit, + json: 1, + }), + }); + + const posts = response.data.post; + + return { + title: tags ? `${tags} - gelbooru.com` : 'gelbooru.com post list', + link: `https://gelbooru.com/index.php?page=post&s=list&tags=${tags}`, + icon: 'https://gelbooru.com/favicon.png', + logo: 'https://gelbooru.com/favicon.png', + description: 'Gelbooru post list', + item: posts.map((post) => ({ + title: post.id, + id: post.id, + link: `https://gelbooru.com/index.php?page=post&s=view&id=${post.id}`, + author: post.owner, + pubDate: parseDate(post.created_at), + description: renderDesc(post, `https://gelbooru.com/index.php?page=post&s=view&id=${post.id}`, quality), + upvotes: post.score, + updated: parseDate(post.change), + media: { + content: { + url: post.file_url, + }, + thumbnail: { + url: post.preview_url, + }, + }, + category: post.tags.split(/\s+/g), + })), + }; +} diff --git a/lib/routes/gelbooru/templates/description.art b/lib/routes/gelbooru/templates/description.art new file mode 100644 index 000000000..85ad3a619 --- /dev/null +++ b/lib/routes/gelbooru/templates/description.art @@ -0,0 +1,19 @@ +
+ {{if isHttp}} + Source + {{else}} + Source: {{source}} + {{/if}} + ({{sourceHost}}) +
+Upload by: {{owner}}
+Score: {{score}}
+Tags:
{{tags}}
diff --git a/lib/routes/gelbooru/utils.ts b/lib/routes/gelbooru/utils.ts new file mode 100644 index 000000000..eb67a61d6 --- /dev/null +++ b/lib/routes/gelbooru/utils.ts @@ -0,0 +1,43 @@ +import { getCurrentPath } from '@/utils/helpers'; +const __dirname = getCurrentPath(import.meta.url); + +import path from 'node:path'; +import { art } from '@/utils/render'; +import { config } from '@/config'; + +export function renderDesc(post, link, quality: 'sample' | 'orig') { + const { id, source, owner, file_url: fileUrl, tags, score } = post; + const isHttp = /^https?:\/\//.test(source); + const sourceHost = isHttp ? new URL(source).host : source || 'unknown'; + const imgQualityMap = { sample: 'sample_url', orig: 'file_url' }; + + // 判断是否是视频链接 + const videoExtList = ['mp4', 'webm']; + const fileExt = fileUrl.slice(fileUrl.lastIndexOf('.') + 1); + const isVideo = videoExtList.includes(fileExt); + // 如果是视频则始终使用 fileUrl(原文件) + let contentURL = post[imgQualityMap[quality]] || fileUrl; + if (isVideo) { + contentURL = fileUrl; + } + + return art(path.join(__dirname, 'templates/description.art'), { + id, + source, + owner, + tags, + link, + isHttp, + sourceHost, + contentURL, + isVideo, + score: score || 0, + }); +} + +export function getAPIKeys() { + return { + apiKey: config.gelbooru.apiKey || '', + userId: config.gelbooru.userId || '', + }; +}