检索功能该怎么写?
想做一个优秀的主机游戏bot,我认为游戏库查询功能必不可少,简单地发送游戏名就可以搜到所有的游戏信息。
以下是框架与源代码:
目标
当收到指令“查询+游戏名”时,自动查询Steam游戏库并输出相关信息。
- 模糊匹配,不需要输入完整的游戏名。
- 中文检索,即使输入中文名也可以正常查找。
- 允许限定搜索范围,例如是否包含DLC、特典等。
- 支持显示不同地区的游戏售价,描述使用中文。
- 受到DNS污染时,支持自动重试。
作为开源插件发布,它的可以让用户配置基本功能:
- 访问Steam的Cookies
- 过滤的关键词
- 需要的游戏信息
设计思路
Steam官方api
查询Steam库的信息,可以解析html网页或是使用API接口,Steam是具有公共api的,显然选择后者更方便。
浏览这份API使用文档后,我从其中找到了几个可以实现目标的接口:“http://api.steampowered.com/ISteamApps/GetAppList/v0001”、“http://store.steampowered.com/api/appdetails/?appids=${appid}”。使用接口检索游戏的方式很简单,首先调取全部的游戏列表,在其中检索所需游戏的英文名称,获取其appid后作为第二个接口的请求参数。
使用“https://api.steampowered.com/IStoreService/GetAppList/v1/?key=${steam_key}&max_results=50000&last_appid=${last_appid}”也可以很方便地对列表进行筛选。
英文检索方式
{
"appid": 444330,
"name": "Battleships At Dawn!"
},
{
"appid": 444350,
"name": "HACK_IT"
},
{
"appid": 444360,
"name": "Industry Manager: Future Technologies - Awesome Products Pack"
},
{
"appid": 444390,
"name": "Neo Aquarium Soundtrack"
},
{
"appid": 444410,
"name": "Find Out"
},
{
"appid": 444420,
"name": "24 Hours 'til Rescue"
JSON从Applist接口返回的json数据如上,程序的目标是找到需要游戏的appid。
ctx
.command("查询 <text:text>", "检索游戏名称")
.action(async ({ session }, text) => {
let response;
response = await axios.get('http://api.steampowered.com/ISteamApps/GetAppList/v0001');
if (response.status === 200) {
appList = response.data.applist.apps.app;
let matchingApps = appList.filter((app) => app.name.match(new RegExp(`\\b(${text.split(' ').join('.*')})\\b`, 'i')));
if (config.keywords.length > 0) {
// 过滤掉与关键词匹配的游戏信息
const filteredApps = matchingApps.filter(app => !config.keywords.some(keyword => new RegExp(`\\b(${keyword.split(' ').join('.*')}).*\\b`, 'i').test(app.name)));
matchingApps = filteredApps;
}
JavaScript上述代码是查询apply函数的一个片段,难点在于如何对英文进行检索。理想中的检索目标不能规定必须输入完整名称,大小写区别,特殊符号等。例如输入“persona™”,可以查询到“Persona 5 Royal”。
RegExp(`\\b(${text.split(' ').join('.*')})\\b`, 'i'))
JavaScript我的解决方式是先对输入的文本去除特殊符号,然后使用以上正则匹配进行检索,它可能无法做到词汇联想,但已经可以实现基本的查询功能。
数据处理
在上述代码中,程序已经从外部映入了数组config.keywords,使用这个数组对查询到的数据进行了第一层的过滤。
const filteredApps = matchingApps.filter(app => !config.keywords.some(keyword => new RegExp(`\\b(${keyword.split(' ').join('.*')}).*\\b`, 'i').test(app.name)));
matchingApps = filteredApps;
JavaScript进一步对matchingApps进行处理:
if (matchingApps.length > 50) {
const newMatchingApps = matchingApps.splice(0, 30);
return `检索到的游戏过多,已为你返回前30个相关性最强的结果。\n${newMatchingApps.map((app) => `游戏名称:${app.name},appid:${app.appid}`).join('\n')}\n请输入你想要查询游戏的appid,例:“id 1941401”`;
} else if (matchingApps.length > 0) {
return `为实现最佳查询效果,请使用英文输入。\n查询到 ${matchingApps.length} 个游戏:\n${matchingApps.map((app) => `游戏名称:${app.name},appid:${app.appid}`).join('\n')}\n请输入你想要查询游戏的appid,例:“id 1941401”`;
} else {
return '为实现最佳查询效果,请使用英文输入。\n未查询到匹配的游戏';
}
} else {
return `请求失败:${response.statusText}`;
}
JavaScript为防止游戏名过短而造成结果过多,首先需要判断matchingApps.length的值,如果结果过长必须进行切片处理,防止消息刷屏。
写到这里似乎只实现了返回游戏的appid,并不能获取任何直观的信息,继续:
const koishi_1 = require("koishi");
ctx
.command("id <appid>", "检索appid")
.alias('id:', 'ID' , 'appid')
JavaScript新建“id”命令用于使用id获取详细信息。
let response;
try {
const link = `https://store.steampowered.com/api/appdetails/?appids=${appid}`;
response = await axios.get(link, {
headers: {
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cookie' : config.Cookie
},
});
} catch (error) {
// 处理错误
console.error(error);
return '请求出现错误,无法获取数据,请检查bot所处网络环境,可以尝试访问https://store.steampowered.com/api/appdetails/?appids=620进行验证';
}
JavaScript使用“https://store.steampowered.com/api/appdetails/?appids=”接口时,我遇到了几个问题:
- 使用浏览器模拟get请求时,返回的结果为中文,但在主机上使用axios.get时需要加上请求头'Accept-Language': 'zh-CN,zh;q=0.9'。
- 同样使用axios.get时,steam返回了美区售价。打开浏览器请求时,返回了国区售价,猜测是Cookie问题,打开无痕浏览后果然也返回了美区售价,因此我在请求头中加入了可自行配置的Cookie选项'Cookie' : config.Cookie。
- 使用“https://store.steampowered.com/api/appdetails”接口时,遇到了DNS污染的情况,可能需要配置科学上网环境,对此程序使用了try方法返回报错。
后续收到插件使用者的反馈,查询游戏信息时偶尔会请求失败,对此引入axios-retry依赖:
const axiosRetry = require('axios-retry');
// Enable axios-retry
axiosRetry(axios, { retries: 3 });
JavaScript请求成功后,继续处理数据:
if (response.status === 200) {
if (response.data[appid] && response.data[appid].data) {
const data = response.data[appid].data;
const values = [];
if (data.name) {
values.push(`名称:${data.name}`);
}
if (data.type && config.type) {
values.push(`类型:${data.type}`);
}
if (data.short_description && config.short_description) {
values.push(`简介:${data.short_description}`);
}
if (data.detailed_description && config.detailed_description) {
values.push(`详细介绍:${data.detailed_description.replace(/<br\/?>/g, '\n')}\n\n`);
}
if (data.genres && config.genres) {
const genreDescriptions = data.genres.map(genre => genre.description).join(', ');
values.push(`分类:${genreDescriptions}`);
}
if (data.categories && config.categories ) {
const categoriesDescriptions = data.categories.map(genre => genre.description).join(', ');
values.push(`标签:${categoriesDescriptions}`);
}
if (data.supported_languages && config.supported_languages) {
values.push(`语言:${data.supported_languages.replace(/<br\/?>/g, '。注:')}`);
}
if (data.platforms && data.platforms.mac && config.platforms) {
values.push(`Mac支持:${data.platforms.mac}`);
}
if (data.pc_requirements.recommended && config.pc_requirements) {
values.push(`PC配置:${data.pc_requirements.recommended.replace(/<br\/?>/g, ',')}\n\n`);
}
if (data.price_overview && config.initial_formatted) {
if(data.price_overview.initial_formatted){
values.push(`原始售价:${data.price_overview.initial_formatted}`);
}
}
if (data.price_overview && config.final_formatted) {
values.push(`\n当前售价:${data.price_overview.final_formatted}`);
}
if (data.metacritic && config.metacritic) {
values.push(`MC均分:${data.metacritic.score}分`);
}
if (data.achievements && config.achievements) {
values.push(`成就总数:${data.achievements.total}个`);
}
if (data.release_date && config.release_date) {
values.push(`发售日期:${data.release_date.date}`);
}
if (response.status === 200) {
values.push(`官方链接:https://store.steampowered.com/app/${appid}`);
}
let imageData = null;
let image1 = null;
let image2 = null;
if (data.header_image) {
imageData = await ctx.http.get(data.header_image, { responseType: 'arraybuffer' });
}
if (data.screenshots && data.screenshots.length >= 1 && data.screenshots[0].path_full) {
image1 = await ctx.http.get(data.screenshots[0].path_full, { responseType: 'arraybuffer' });
}
if (data.screenshots && data.screenshots.length >= 2 && data.screenshots[1].path_full) {
image2 = await ctx.http.get(data.screenshots[1].path_full, { responseType: 'arraybuffer' });
}
// 将图片数据拼接在返回的字符串中
let result = values.join('\n');
if (imageData) {
result += koishi_1.segment.image(imageData);
}
if (image1) {
result += koishi_1.segment.image(image1);
}
if (image2) {
result += koishi_1.segment.image(image2);
}
return result;
}
} else {
return `请求失败:${response.statusText}`;
}
});
JavaScript对数据进行处理时,最常碰见的就是undefined报错,因为并未所有游戏都包含了配置项,例如没有打折的游戏就不存在“data.price_overview.initial_formatted”,因此我使用values.push方法,先判断数据是否存在然后再拼接字符串。
据反馈,当配置国区Cookies后,调用某些锁区游戏(点名科乐美)数据时,steam接口可能会直接拒绝。因此加上报错:
else {
return 'appid不存在或该游戏锁区,请检查您配置的Cookie是否有权限访问该游戏。';
}
JavaScript至此id命令编写完毕,但到此为止的话,查一个游戏岂不是还要使用两个命令?那为何不打开steam自己看...必须想办法精简这些步骤!
为此,我使用了koishi的库函数session.prompt、session.execute。
session.prompt((session) => {
const appid = session.content.match(/\d+/);
if (appid) {
return session.execute(`id ${appid[0]}`);
} else {
return session.send("请输入正确的appid");
}
JavaScript当用户发送查询命令后,无数再输入id指令,输入纯数字即可完成查询。
else if (matchingApps.length === 1) {
const appid = matchingApps[0].appid;
session.execute(`id ${appid}`);
JavaScript如果筛选后的结果只有一个,那么自动执行id指令,配合上关键词过滤,一行指令即可完成游戏的查询!