koishi-plugin-mediawiki
Version:
MediaWiki for koishijs
438 lines (436 loc) • 17.1 kB
JavaScript
var _PluginMediawiki_instances, _PluginMediawiki_initCommands;
import { __classPrivateFieldGet } from "tslib";
/**
* koishi-plugin-mediawiki
* @desc MediaWiki plugin for Koishijs
* @author Koishijs(机智的小鱼君) <dragon-fish@qq.com>
* @license Apache-2.0
*/
import { h, Schema } from 'koishi';
import { getUrl, getWikiDisplayTitle, isValidApi, parseTitlesFromText, useApi, } from './utils/wiki';
import { INFOBOX_DEFINITION } from './infoboxes';
import { BulkMessageBuilder } from './utils/BulkMessageBuilder';
const DEFAULT_CONFIGS = {
cmdAuthWiki: 1,
cmdAuthConnect: 2,
cmdAuthSearch: 1,
searchIfNotExist: false,
showDetailsByDefault: false,
customInfoboxes: [],
};
export const name = 'mediawiki';
class PluginMediawiki {
constructor(ctx, config = DEFAULT_CONFIGS) {
_PluginMediawiki_instances.add(this);
this.ctx = ctx;
this.config = config;
this.INFOBOX_DEFINITION = [
...(this.config.customInfoboxes || []),
...INFOBOX_DEFINITION,
];
this.config = { ...DEFAULT_CONFIGS, ...config };
ctx.model.extend('channel', {
mwApi: 'string',
});
__classPrivateFieldGet(this, _PluginMediawiki_instances, "m", _PluginMediawiki_initCommands).call(this);
}
get logger() {
return this.ctx.logger('mediawiki');
}
async shotInfobox(url, silence = false) {
if (!this.ctx.puppeteer) {
return silence ? '' : '无法获取截图,请安装 puppeteer 插件后再试~';
}
const matched = this.INFOBOX_DEFINITION.find((i) => {
if (typeof i.match === 'string') {
return new RegExp(i.match).test(url);
}
else {
return i.match(new URL(url));
}
});
if (!matched)
return '';
this.logger.info('SHOT_INFOBOX', url, matched.selector);
const start = Date.now();
const timeSpend = () => ((Date.now() - start) / 1000).toFixed(3) + 's';
const pageURL = new URL(url);
pageURL.searchParams.set('useskin', matched.skin || '');
let pageLoaded = false;
const page = await this.ctx.puppeteer.page();
await page.setViewport({ width: 960, height: 720 });
try {
// 开始竞速,load 事件触发后最多再等 5s
await Promise.race([
page.goto(pageURL.href, {
timeout: 10 * 1000,
waitUntil: 'networkidle0',
}),
new Promise((resolve) => {
page.on('load', () => {
console.info('[TIMER]', 'page loaded', timeSpend());
pageLoaded = true;
setTimeout(() => resolve(1), 5 * 1000);
});
}),
]);
}
catch (e) {
console.info('[TIMER]', 'Navigation timeout', timeSpend());
this.logger.warn('SHOT_INFOBOX', 'Navigation timeout:', `(page HAS ${pageLoaded ? '' : 'NOT'} loaded)`, e);
await page
.$('.mw-parser-output')
.then((i) => {
this.logger.info('SHOT_INFOBOX', '`.mw-parser-output` exist, render it anyway');
pageLoaded = true;
return i;
})
.catch(() => { });
if (!pageLoaded) {
await page.close();
return '';
}
}
try {
await page.addStyleTag({
content: this.createInjectStylesFromDefinition(matched),
});
}
catch (e) {
this.logger.warn('SHOT_INFOBOX', 'Inject styles error', e);
}
try {
const target = await page.$(Array.isArray(matched.selector)
? matched.selector.join(', ')
: matched.selector);
if (!target) {
this.logger.info('SHOT_INFOBOX', 'Canceled', 'Missing target');
await page.close();
return '';
}
const img = await target.screenshot({ type: 'jpeg', quality: 85 });
console.info('[TIMER]', 'OK', timeSpend());
this.logger.info('SHOT_INFOBOX', 'OK', img);
await page.close();
return h.image(img, 'image/jpeg');
}
catch (e) {
this.logger.warn('SHOT_INFOBOX', 'Failed', e);
await page?.close();
return '';
}
}
createInjectStylesFromDefinition({ selector, injectStyles, }) {
return `
/* 隐藏妨碍截图的元素 */
${Array.isArray(selector) ? selector.join(', ') : selector} {
visibility: visible;
:not(&, & *) {
visibility: hidden;
}
}
/* 配置定义 */
${injectStyles}
`;
}
}
_PluginMediawiki_instances = new WeakSet(), _PluginMediawiki_initCommands = function _PluginMediawiki_initCommands() {
// @command wiki
this.ctx
.command('wiki [titles:text]', 'MediaWiki 相关功能', {
authority: this.config.cmdAuthWiki,
})
.example('wiki 页面 - 获取页面链接')
.channelFields(['mwApi'])
.option('details', '-d 显示页面的更多资讯', {
type: 'boolean',
fallback: this.config.showDetailsByDefault,
})
.option('search', '-s 如果页面不存在就进行搜索', {
type: 'boolean',
fallback: this.config.searchIfNotExist,
})
.option('quiet', '-q 静默执行(忽略未绑定提示)', {
type: 'boolean',
// @ts-ignore
hidden: true,
})
.action(async ({ session, options }, titlesInput = '') => {
if (!session?.channel)
throw new Error('Missing channel context');
const { mwApi } = session.channel;
// Missing connection init
if (!mwApi) {
return options?.quiet ? '' : session.execute('wiki.connect -h');
}
// Missing titles
if (!titlesInput) {
return getUrl(mwApi);
}
// Generate API client
const api = useApi(mwApi);
// 去重并缓存用户输入的标题及锚点
const titles = Array.from(new Set(titlesInput.split('|').map(getWikiDisplayTitle)))
.map((i) => {
return {
name: i.split('#')[0],
anchor: i.split('#')[1] ? '#' + encodeURI(i.split('#')[1]) : '',
};
})
.filter((i) => !!i.name)
.slice(0, 5);
const { data } = await api
.get({
action: 'query',
prop: 'extracts|info',
meta: 'siteinfo',
siprop: 'specialpagealiases|namespacealiases|namespaces',
iwurl: 1,
titles: titles.map((i) => i.name),
redirects: 1,
converttitles: 1,
exchars: '120',
exlimit: 'max',
explaintext: 1,
exintro: 1,
exsectionformat: 'plain',
inprop: 'url|displaytitle',
})
.catch((e) => {
session.send(`查询时遇到问题:${e || '-'}`);
throw e;
});
this.logger.debug('QUERY DATA', data.query);
// Cache variables
const { pages, redirects, interwiki, specialpagealiases, namespaces } = data.query;
/**
* @desc 某些特殊页面会暴露服务器 IP 地址,必须特殊处理这些页面
* 已知的危险页面包括 Mypage Mytalk
*/
// 这里用标准名称
const dangerPageNames = ['Mypage', 'Mytalk'];
// 获取全部别名
const dangerPages = specialpagealiases
.filter((i) => dangerPageNames.includes(i.realname))
.map((i) => i.aliases)
.flat(Infinity);
// 获取本地特殊名字空间的标准名称
const specialNsName = namespaces['-1'].name;
const pageMsgs = pages?.map((page) => {
// Cache variables
const msg = [];
let pageRedirect = redirects?.find(({ to }) => to === page.title);
let pageAnchor = titles.find((i) => i.name.toLocaleLowerCase() === page.title.toLocaleLowerCase())?.anchor || '';
// 开始判断危险重定向
if (
// 发生重定向
pageRedirect &&
// 重定向自特殊页面
pageRedirect.from.split(':')[0] === specialNsName &&
// 被标记为危险页面
dangerPages.includes(pageRedirect.from.split(':')?.[1].split('/')[0] || '')) {
// 覆写页面资料
page = {
...page,
ns: -1,
title: pageRedirect.from,
special: true,
};
// 重置重定向信息
pageRedirect = undefined;
delete page.missing;
}
const { pageid, title: pagetitle, missing, invalid,
// extract,
canonicalurl, special, editurl, } = page;
// 打印开头
msg.push(`您要的“${pagetitle}”:`);
/** 处理特殊情况 */
// 重定向
if (pageRedirect) {
const { from, to, tofragment } = pageRedirect || {};
msg.push(`重定向:[${from}] → [${to}${tofragment ? '#' + tofragment : ''}]`);
if (tofragment)
pageAnchor = '#' + encodeURI(tofragment);
}
// 页面名不合法
if (invalid !== undefined) {
msg.push(`😟页面名称不合法:${JSON.stringify(page.invalidreason) || '原因未知'}`);
}
// 特殊页面
else if (special) {
msg.push(`${getUrl(mwApi, {
title: pagetitle,
})}${pageAnchor} (${missing ? '不存在的' : ''}特殊页面)`);
}
// 不存在页面
else if (missing !== undefined) {
if (!options?.search) {
msg.push(`${editurl} (💔页面不存在)`);
}
else {
msg.push(`${editurl}\n💡页面不存在,即将搜索wiki……`);
}
}
else {
const shortUrl = getUrl(mwApi, { curid: pageid });
msg.push((shortUrl.length <= canonicalurl.length
? shortUrl
: canonicalurl) + pageAnchor);
}
if (options?.details && page.extract) {
msg.push(page.extract);
}
return msg.join('\n');
}) || [];
const interwikiMsgs = interwiki?.map((item) => {
return [`跨语言链接:`, item.url].join('\n');
}) || [];
const allMsgList = [...pageMsgs, ...interwikiMsgs];
let finalMsg = '';
if (allMsgList.length === 1) {
finalMsg = h.quote(session.messageId) + allMsgList[0];
}
else if (allMsgList.length > 1) {
const msgBuilder = new BulkMessageBuilder(session);
allMsgList.forEach((i) => {
msgBuilder.botSay(i);
});
finalMsg = msgBuilder.prependOriginal().all();
}
// 结果有且仅有一个存在的主名字空间的页面
if (pages &&
pages.length === 1 &&
pages[0].ns === 0 &&
!pages[0].missing &&
!pages[0].invalid) {
await session.send(finalMsg);
session.send(await this.shotInfobox(pages[0].canonicalurl, true));
}
// 结果有且仅有一个不存在的主名字空间的页面
else if (options?.search &&
pages.length === 1 &&
pages[0].ns === 0 &&
pages[0].missing &&
!pages[0].invalid) {
await session.send(finalMsg);
await session.execute(`wiki.search ${pages[0].title}`);
}
// 其他情况
else {
return finalMsg;
}
});
this.ctx.middleware(async (session, next) => {
await next();
const titles = parseTitlesFromText(session.content || '');
if (!titles.length) {
return;
}
session.execute(`wiki -q ${titles.join('|')}`);
});
// @command wiki.connect
// @command wiki.link
this.ctx
.command('wiki.connect [api:string]', '将群聊与 MediaWiki 网站连接', {
authority: this.config.cmdAuthConnect,
})
.alias('wiki.link')
.channelFields(['mwApi'])
.action(async ({ session }, api) => {
if (!session?.channel)
throw new Error();
const { channel } = session;
if (!api) {
return channel.mwApi
? `本群已与 ${channel.mwApi} 连接~`
: '本群未连接到 MediaWiki 网站,请使用“wiki.connect <api网址>”进行连接。';
}
else if (isValidApi(api)) {
channel.mwApi = api;
await session.channel.$update();
return session.execute('wiki.connect');
}
else {
return '输入的不是合法 api.php 网址。';
}
});
// @command wiki.search
this.ctx
.command('wiki.search [srsearch:text]')
.channelFields(['mwApi'])
.action(async ({ session }, keywords) => {
if (!session?.channel?.mwApi) {
return session?.execute('wiki.connect -h');
}
if (!keywords) {
session.sendQueued('要搜索什么呢?(输入空行或句号取消)');
keywords = (await session.prompt(30 * 1000)).trim();
if (!keywords || keywords === '.' || keywords === '。')
return '';
}
const api = useApi(session.channel.mwApi);
const { data: { query: { searchinfo, search, pages }, }, } = await api.post({
action: 'query',
prop: 'extracts',
list: 'search',
generator: 'search',
exchars: '120',
exintro: 1,
explaintext: 1,
exsectionformat: 'plain',
srsearch: keywords,
srnamespace: '0',
srlimit: '5',
srinfo: 'totalhits',
srprop: '',
gsrsearch: keywords,
gsrnamespace: '0',
gsrlimit: '5',
});
const bulk = new BulkMessageBuilder(session);
if (search.length < 1) {
return `💔找不到与“${keywords}”匹配的结果。`;
}
else if (search.length === 1) {
return session.execute(`wiki -d ${search[0].title}`);
}
else {
bulk.prependOriginal();
bulk.botSay(`🔍关键词“${keywords}”共匹配到 ${searchinfo?.totalhits || '∅'} 个相关结果,我来简单整理一下前 ${search.length} 个结果:`);
}
pages
.sort((a, b) => a.index - b.index)
.forEach((item, index) => {
bulk.botSay(`(${index + 1}) ${item.title}
${item.extract}
${getUrl(session.channel.mwApi, { curid: item.pageid })}`);
});
return bulk.all();
});
};
PluginMediawiki.inject = ['database'];
PluginMediawiki.Config = Schema.object({
cmdAuthWiki: Schema.number()
.description('指令`wiki`的权限等级:基础指令,请求条目链接与基本信息等')
.default(1),
cmdAuthConnect: Schema.number()
.description('指令`wiki.connect`的权限等级:将wiki绑定到群聊')
.default(2),
cmdAuthSearch: Schema.number()
.description('指令`wiki.search`的权限等级:在绑定的wiki中搜索')
.default(1),
searchIfNotExist: Schema.boolean().description('触发`wiki`指令时,结果有且仅有一个不存在的主名字空间的页面时否自动触发搜索'),
showDetailsByDefault: Schema.boolean().description('触发`wiki`指令时,是否默认附带页面摘要和信息框截图'),
customInfoboxes: Schema.array(Schema.object({
match: Schema.string()
.description('正则表达式,决定该组信息框定义是否匹配当前请求的URL。(URL示例 `https://example.com/wiki/PageName`,填写示例:`^https?://example\\\\.com/`)')
.required(),
selector: Schema.array(String).description('信息框的选择器').required(),
injectStyles: Schema.string()
.description('额外插入的CSS')
.role('textarea'),
skin: Schema.string().description('渲染时使用的皮肤,建议配置为 `apioutput` 提高加载速度'),
})).description('自定义信息框定义组,每一个定义组至少需要match以及selector'),
});
export default PluginMediawiki;