koishi-plugin-booru-pixiv
Version:
Image service from pixiv.net for Koishi
367 lines (361 loc) • 16.8 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
// packages/pixiv/src/locales/zh-CN.schema.yml
var require_zh_CN_schema = __commonJS({
"packages/pixiv/src/locales/zh-CN.schema.yml"(exports, module2) {
module2.exports = { $description: "搜索设置", $inner: [{ endpoint: "Pixiv 的 API Root", token: "Pixiv 的 Refresh Token", target: { $description: "搜索目标类型", $inner: ["标签部分一致", "标签完全一致", "标题或介绍说明"] }, sort: { $description: "搜索结果排序方式", $inner: ["上传时间较近优先", "上传时间较远优先", "收藏数较多优先 (需要 Pixiv Premium)"] }, duration: { $description: "搜索时间范围", $inner: ["24 小时内", "一周内", "一个月内", "全部时间", "自定义"] }, minBookmarks: "最少收藏数,仅在设置了 Token 并有 Pixiv Premium 的情况下可用", maxBookmarks: "最大收藏数,仅在设置了 Token 并有 Pixiv Premium 的情况下可用", rank: { $description: "年龄分级", $inner: ["全年龄", "R18", "R18G"] }, ai: { $description: "是否允许搜索AI作品", $inner: ["不允许AI作品", "允许AI作品"] } }, { bypassMethod: { $description: "突破 Pixiv 站点图片防外部引用检测的方式。[参考](https://booru.koishi.chat/zh-CN/plugins/pixiv.html#bypass-pixiv-detection)", $inner: ["使用现有反代服务", "使用插件本地反代", "下载到 assets 缓存"] }, proxy: { $description: "Pixiv 反代服务。", $inner: ["i.pixiv.re", "i.pixiv.cat", "i.pixiv.nl", { $description: "自定义", endpoint: "反代服务的地址。" }] }, route: "反代服务的路径(需在 server 插件配置中填写 `selfUrl`)。", aesKey: "AES 加密密钥" }] };
}
});
// packages/pixiv/src/index.ts
var src_exports = {};
__export(src_exports, {
default: () => src_default
});
module.exports = __toCommonJS(src_exports);
var import_node_crypto = require("node:crypto");
var import_node_stream = require("node:stream");
var import_koishi2 = require("koishi");
var import_koishi_plugin_booru = require("koishi-plugin-booru");
// packages/pixiv/src/types.ts
var PixivAppApi;
((PixivAppApi2) => {
let SearchAIType;
((SearchAIType2) => {
SearchAIType2[SearchAIType2["SHOW_AI"] = 0] = "SHOW_AI";
SearchAIType2[SearchAIType2["HIDE_AI"] = 1] = "HIDE_AI";
})(SearchAIType = PixivAppApi2.SearchAIType || (PixivAppApi2.SearchAIType = {}));
})(PixivAppApi || (PixivAppApi = {}));
// packages/pixiv/src/utils.tsx
var import_koishi = require("koishi");
var import_jsx_runtime = require("@satorijs/element/jsx-runtime");
function normaliseCaption(caption) {
if (!(caption == null ? void 0 : caption.trim())) {
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("text", {});
}
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, {
children: import_koishi.h.transform(import_koishi.h.parse(caption), {
a(attrs) {
var _a, _b, _c;
let url = attrs["href"] || "";
if (url) {
let m = null;
if (m = /pixiv:\/\/users\/(?<id>\d+)/.exec(url), (_a = m == null ? void 0 : m.groups) == null ? void 0 : _a.id) {
url = `https://www.pixiv.net/u/${m.groups.id}`;
} else if (m = /pixiv:\/\/illusts\/(?<id>\d+)/.exec(url), (_b = m == null ? void 0 : m.groups) == null ? void 0 : _b.id) {
url = `https://www.pixiv.net/i/${m.groups.id}`;
} else if (m = /pixiv:\/\/novels\/(?<id>\d+)/.exec(url), (_c = m == null ? void 0 : m.groups) == null ? void 0 : _c.id) {
url = `https://www.pixiv.net/novel/show.php?id=${m.groups.id}`;
}
}
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", {
href: url,
children: attrs.children || []
});
}
})
});
}
__name(normaliseCaption, "normaliseCaption");
// packages/pixiv/src/index.ts
var CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
var CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
var PixivImageSource = class extends import_koishi_plugin_booru.ImageSource {
languages = ["en", "zh", "zh-CN", "zh-TW", "ja", "ko"];
source = "pixiv";
userId;
accessToken;
refreshToken;
refreshTime;
logger = this.ctx.logger("booru-pixiv");
constructor(ctx, config) {
var _a;
super(ctx, config);
this.refreshToken = config.token;
if (config.bypassMethod === "route") {
if (!config.route || !((_a = this.ctx.server) == null ? void 0 : _a.selfUrl)) {
throw new Error('route and selfUrl are required for bypass method "route".');
}
if (!config.aesKey) {
const aesKey = (0, import_node_crypto.randomBytes)(32).toString("hex");
config.aesKey = aesKey;
this.ctx.setTimeout(() => ctx.scope.update(config, false), 0);
this.logger.info("Found empty aesKey with a bypass method set to 'route', generated a random one in config.");
}
this.ctx.server.get((0, import_koishi2.trimSlash)(config.route) + "/:url(.+)", async (ctx2, next) => {
const url = ctx2.request.url.replace(/^\/booru\/pixiv\/proxy\//, "");
const decrypted = Cipher.decrypt(decodeURIComponent(url), config.aesKey);
if (typeof decrypted !== "string" || !decrypted.startsWith("https://i.pximg.net/"))
return next();
const file = await this.http(decrypted, {
headers: { Referer: "https://www.pixiv.net/" },
responseType: "stream"
});
ctx2.set(Object.fromEntries(file.headers.entries()));
ctx2.remove("Content-Length");
ctx2.response.status = file.status;
ctx2.response.message = file.statusText;
ctx2.body = import_node_stream.Readable.fromWeb(file.data);
return next();
});
}
}
tokenize(query) {
return query.split(/\s+/);
}
async get(query) {
var _a;
try {
const data = await (query.raw.length ? this.search(query.tags.join(" ")) : this.recommend());
return Promise.all(
import_koishi2.Random.shuffle(
data.illusts.filter((illust) => illust.total_bookmarks > this.config.minBookmarks).filter((illust) => illust.x_restrict <= this.config.rank).filter((illust) => illust.illust_ai_type <= this.config.ai)
).slice(0, query.count).map(async (illust) => {
let url = "";
if (illust.page_count > 1) {
url = illust.meta_pages[0].image_urls.original;
} else {
url = illust.meta_single_page.original_image_url;
}
return {
urls: {
original: await this._handleImage(url)
},
title: illust.title,
pageUrl: `https://pixiv.net/i/${illust.id}`,
author: illust.user.name,
authorUrl: `https://pixiv.net/u/${illust.user.id}`,
desc: normaliseCaption(illust.caption),
tags: illust.tags.map((tag) => tag.name),
nsfw: illust.x_restrict >= 1
};
})
);
} catch (err) {
if (import_koishi2.HTTP.Error.is(err)) {
throw new Error(`get pixiv image failed: ${err.message} (${(_a = err.response) == null ? void 0 : _a.status})`);
} else {
throw new Error("get pixiv image failed: " + err);
}
}
}
async search(keyword) {
const url = "/v1/search/illust";
const params = {
word: keyword,
search_target: this.config.target,
search_ai_type: this.config.ai === 2 ? PixivAppApi.SearchAIType.SHOW_AI : PixivAppApi.SearchAIType.HIDE_AI,
sort: this.config.sort,
duration: this.config.duration !== "all" && this.config.duration !== "custom" ? this.config.duration : void 0,
min_bookmarks: this.config.minBookmarks || void 0,
max_bookmarks: this.config.maxBookmarks || void 0,
filter: "for_ios"
};
Object.keys(params).forEach((key) => {
if (params[key] === void 0) {
delete params[key];
}
});
if (!this.accessToken) {
await this._login();
}
return await this.http.get((0, import_koishi2.trimSlash)(this.config.endpoint) + url, {
params,
headers: this._getHeaders()
});
}
async recommend() {
const url = "/v1/illust/recommended";
if (!this.accessToken) {
await this._login();
}
return await this.http.get((0, import_koishi2.trimSlash)(this.config.endpoint) + url, {
params: {
content_type: "illust",
include_ranking_label: true,
filter: "for_ios"
},
headers: this._getHeaders()
});
}
async _login() {
var _a;
const endpoint = "https://oauth.secure.pixiv.net/";
const url = (0, import_koishi2.trimSlash)(endpoint) + "/auth/token";
const data = new URLSearchParams({
get_secure_url: "true",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: this.refreshToken
});
try {
const resp = await this.http.post(url, data, {
headers: {
...this._getHeaders(),
"Content-Type": "application/x-www-form-urlencoded",
"host": "oauth.secure.pixiv.net"
},
validateStatus: (status) => [200, 301, 302].includes(status)
});
this.userId = resp.user.id;
this.accessToken = resp.access_token;
this.refreshToken = resp.refresh_token;
if (this.refreshTime)
clearTimeout(this.refreshTime);
this.refreshTime = setTimeout(() => this.accessToken = void 0, resp.expires_in * 1e3);
return this.accessToken;
} catch (err) {
if (import_koishi2.HTTP.Error.is(err)) {
throw new Error("Login failed with status code " + ((_a = err.response) == null ? void 0 : _a.status) + "\n" + JSON.stringify(err.response));
} else {
throw new Error("Login failed with unknown error: " + err.message);
}
}
}
_getHeaders() {
const headers = {
"app-os": "ios",
"app-os-version": "14.6",
"user-agent": "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)"
};
if (this.refreshToken && this.accessToken) {
headers.Authorization = "Bearer " + this.accessToken;
}
return headers;
}
async _handleImage(url) {
if (this.config.bypassMethod === "proxy" && this.config.proxy) {
const proxy = typeof this.config.proxy === "string" ? this.config.proxy : this.config.proxy.endpoint;
return url.replace(/^https?:\/\/i\.pximg\.net/, (0, import_koishi2.trimSlash)(proxy));
} else if (this.config.bypassMethod === "route" && this.config.route && this.ctx.get("server")) {
const encrypted = Cipher.encrypt(url, this.config.aesKey);
return (0, import_koishi2.trimSlash)(this.ctx.server.config.selfUrl) + (0, import_koishi2.trimSlash)(this.config.route) + "/" + encodeURIComponent(encrypted);
} else if (this.config.bypassMethod === "asset" && this.ctx.get("assets")) {
const filename = url.split("/").pop().split("?")[0];
const file = await this.http(url, { headers: { Referer: "https://www.pixiv.net/" } });
const base64 = Buffer.from(file.data).toString("base64");
return this.ctx.assets.upload(`data:${file.headers["content-type"]};base64,${base64}`, filename);
} else {
this.logger.warn(
`Bypass method is set to ${this.config.bypassMethod}, but there's no candidate to handle the image.`
);
return url;
}
}
};
__name(PixivImageSource, "PixivImageSource");
__publicField(PixivImageSource, "inject", {
required: ["booru"],
optional: ["assets", "server"]
});
((PixivImageSource2) => {
PixivImageSource2.Config = import_koishi2.Schema.intersect([
import_koishi_plugin_booru.ImageSource.createSchema({ label: "pixiv" }),
import_koishi2.Schema.intersect([
import_koishi2.Schema.object({
endpoint: import_koishi2.Schema.string().default("https://app-api.pixiv.net/"),
token: import_koishi2.Schema.string().required().role("secret"),
target: import_koishi2.Schema.union([
import_koishi2.Schema.const("partial_match_for_tags"),
import_koishi2.Schema.const("exact_match_for_tags"),
import_koishi2.Schema.const("title_and_caption")
]).default("partial_match_for_tags"),
sort: import_koishi2.Schema.union([import_koishi2.Schema.const("date_desc"), import_koishi2.Schema.const("date_asc"), import_koishi2.Schema.const("popular_desc")]).default(
"date_desc"
),
duration: import_koishi2.Schema.union([
import_koishi2.Schema.const("within_last_day"),
import_koishi2.Schema.const("within_last_week"),
import_koishi2.Schema.const("within_last_month"),
import_koishi2.Schema.const("all"),
import_koishi2.Schema.const("custom").disabled()
]).default("all"),
minBookmarks: import_koishi2.Schema.number().default(0),
maxBookmarks: import_koishi2.Schema.number().default(0),
rank: import_koishi2.Schema.union([import_koishi2.Schema.const(0), import_koishi2.Schema.const(1), import_koishi2.Schema.const(2)]).default(0),
ai: import_koishi2.Schema.union([import_koishi2.Schema.const(1), import_koishi2.Schema.const(2)]).default(1)
}),
import_koishi2.Schema.intersect([
import_koishi2.Schema.object({
bypassMethod: import_koishi2.Schema.union([import_koishi2.Schema.const("proxy"), import_koishi2.Schema.const("route"), import_koishi2.Schema.const("asset")]).default(
"proxy"
)
}),
import_koishi2.Schema.union([
import_koishi2.Schema.object({
bypassMethod: import_koishi2.Schema.const("proxy"),
proxy: import_koishi2.Schema.union([
import_koishi2.Schema.const("https://i.pixiv.re"),
import_koishi2.Schema.const("https://i.pixiv.cat"),
import_koishi2.Schema.const("https://i.pixiv.nl"),
import_koishi2.Schema.object({
endpoint: import_koishi2.Schema.string().required()
})
]).default("https://i.pixiv.re")
}),
import_koishi2.Schema.object({
bypassMethod: import_koishi2.Schema.const("route"),
route: import_koishi2.Schema.string().default("/booru/pixiv/proxy"),
aesKey: import_koishi2.Schema.string().hidden().default("")
}),
import_koishi2.Schema.object({
bypassMethod: import_koishi2.Schema.const("asset")
})
])
])
]).i18n({
"zh-CN": require_zh_CN_schema()
})
]);
})(PixivImageSource || (PixivImageSource = {}));
var Cipher = class {
static encrypt(data, key) {
try {
const iv = (0, import_node_crypto.randomBytes)(16);
const cipher = (0, import_node_crypto.createCipheriv)("aes-256-cbc", Buffer.from(key, "hex"), iv);
const encrypted = Buffer.concat([iv, cipher.update(data), cipher.final()]);
return encrypted.toString("base64");
} catch (err) {
return null;
}
}
static decrypt(data, key) {
try {
const encrypted = Buffer.from(data, "base64");
const iv = encrypted.slice(0, 16);
const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-cbc", Buffer.from(key, "hex"), iv);
const decrypted = Buffer.concat([decipher.update(encrypted.slice(16)), decipher.final()]);
return decrypted.toString();
} catch (err) {
return null;
}
}
};
__name(Cipher, "Cipher");
var src_default = PixivImageSource;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {});