fix: 修复 QQBot FF14 中文物品解析

This commit is contained in:
sunlei 2026-06-02 15:56:07 +08:00
parent c8861a1355
commit ae2fd41e0f
6 changed files with 167 additions and 67 deletions

View File

@ -55,6 +55,7 @@ MQTT_PASSWORD=
MQTT_CLIENT_ID=kt-template-online-api-qqbot
FF14_XIVAPI_BASE_URL=https://v2.xivapi.com/api
FF14_XIVAPI_CHS_BASE_URL=https://xivapi-v2.xivcdn.com/api
FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2
FF14_DEFAULT_WORLD=中国
FF14_MARKET_CACHE_TTL_MS=60000

View File

@ -315,7 +315,7 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`)
VALUES
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!",""]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"zh","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 0, 1500, '默认示例命令;默认查询范围为中国,可按需改为具体服务器')
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!",""]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 0, 0, 1500, '默认示例命令;默认查询范围为中国,可按需改为具体服务器')
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`plugin_key` = VALUES(`plugin_key`),

View File

@ -91,24 +91,38 @@ export class QqbotCommandEngineService {
}
const input = this.mergeInput(command, matched.input);
const output = await this.pluginRegistry.execute(
command.pluginKey,
command.operationKey,
input,
{
args: matched.input,
command,
message,
},
);
const replyText = this.buildReplyText(command, input, output);
return {
command: this.commandService.toResponse(command),
input,
matched: true,
output,
replyText,
};
try {
const output = await this.pluginRegistry.execute(
command.pluginKey,
command.operationKey,
input,
{
args: matched.input,
command,
message,
},
);
const replyText = this.buildReplyText(command, input, output);
return {
command: this.commandService.toResponse(command),
input,
matched: true,
output,
replyText,
status: 'success',
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '命令执行失败';
return {
command: this.commandService.toResponse(command),
errorMessage,
input,
matched: true,
output: null,
replyText: this.buildErrorReplyText(command, input, errorMessage),
status: 'failed',
};
}
}
private async findMatchedCommand(message: QqbotNormalizedMessage) {
@ -140,11 +154,7 @@ export class QqbotCommandEngineService {
message: QqbotNormalizedMessage,
errorMessage: string,
) {
const reply =
this.replyTemplate.render(command.errorTemplate, {
error: errorMessage,
input,
}) || `命令执行失败:${errorMessage}`;
const reply = this.buildErrorReplyText(command, input, errorMessage);
try {
await this.sendService.sendText({
channelId: message.channelId,
@ -162,6 +172,19 @@ export class QqbotCommandEngineService {
}
}
private buildErrorReplyText(
command: QqbotCommand,
input: Record<string, any>,
errorMessage: string,
) {
return (
this.replyTemplate.render(command.errorTemplate, {
error: errorMessage,
input,
}) || `命令执行失败:${errorMessage}`
);
}
private mergeInput(command: QqbotCommand, input: Record<string, any>) {
return {
...this.commandService.parseDefaultParams(command),

View File

@ -131,7 +131,7 @@ export class QqbotCommandParserService {
dataCenter,
hq: this.normalizeHq(flags.get('hq')),
item,
language: this.normalizeString(flags.get('lang')) || 'zh',
language: this.normalizeString(flags.get('lang')) || 'chs',
raw: rawArgs,
region,
world,

View File

@ -8,9 +8,9 @@ type HttpMethod = 'GET';
type XivapiSearchItem = {
fields?: {
Icon?: string;
Icon?: string | { path?: string; path_hr1?: string };
IsUntradable?: boolean;
LevelItem?: number;
LevelItem?: number | { row_id?: number; value?: number };
Name?: string;
};
id?: number;
@ -64,19 +64,16 @@ export type QqbotFf14PriceResult = {
@Injectable()
export class QqbotFf14ClientService {
private readonly xivapiBaseUrl: string;
private readonly xivapiChsBaseUrl: string;
private readonly universalisBaseUrl: string;
private readonly cnItemAliases = new Map<
string,
{ itemId: number; name: string }
>([
['小鸣鼠', { itemId: 43590, name: '小鸣鼠角笛' }],
['小鸣鼠角笛', { itemId: 43590, name: '小鸣鼠角笛' }],
]);
constructor(private readonly configService: ConfigService) {
this.xivapiBaseUrl =
this.configService.get<string>('FF14_XIVAPI_BASE_URL') ||
'https://v2.xivapi.com/api';
this.xivapiChsBaseUrl =
this.configService.get<string>('FF14_XIVAPI_CHS_BASE_URL') ||
'https://xivapi-v2.xivcdn.com/api';
this.universalisBaseUrl =
this.configService.get<string>('FF14_UNIVERSALIS_BASE_URL') ||
'https://universalis.app/api/v2';
@ -96,34 +93,14 @@ export class QqbotFf14ClientService {
const keyword = `${params.item || ''}`.trim();
if (!keyword) throw new Error('请提供 FF14 物品名称或物品 ID');
const alias = this.resolveCnItemAlias(keyword);
if (alias) {
return this.getItemById(alias.itemId, language, alias.name);
}
const url = new URL(`${this.xivapiBaseUrl}/search`);
url.searchParams.set('sheets', 'Item');
url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable');
url.searchParams.set('query', `Name~"${this.escapeXivapiValue(keyword)}"`);
url.searchParams.set('language', language);
url.searchParams.set('limit', '10');
const data = await this.requestJson<{ results?: XivapiSearchItem[] }>(
url,
'GET',
'XIVAPI 物品解析',
);
const item = (data.results || []).find(
(result) =>
result.sheet === 'Item' || result.fields?.Name || result.name,
);
const item = await this.searchItem(keyword, language);
if (!item) throw new Error(`未找到 FF14 物品:${keyword}`);
return {
icon: item.fields?.Icon,
icon: this.normalizeItemIcon(item.fields?.Icon),
isUntradable: item.fields?.IsUntradable,
itemId: Number(item.row_id || item.id),
itemLevel: item.fields?.LevelItem,
itemLevel: this.normalizeItemLevel(item.fields?.LevelItem),
name: item.fields?.Name || item.name || keyword,
};
}
@ -198,12 +175,16 @@ export class QqbotFf14ClientService {
private async getItemById(
itemId: number,
language = 'zh',
language = 'chs',
displayName?: string,
): Promise<QqbotFf14ResolvedItem> {
const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`);
const normalizedLanguage = this.normalizeXivapiLanguage(language);
const url = this.buildXivapiUrl(
`/sheet/Item/${itemId}`,
normalizedLanguage,
);
url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable');
url.searchParams.set('language', this.normalizeXivapiLanguage(language));
url.searchParams.set('language', normalizedLanguage);
const data = await this.requestJson<Record<string, any>>(
url,
'GET',
@ -211,10 +192,10 @@ export class QqbotFf14ClientService {
);
const fields = data.fields || data;
return {
icon: fields.Icon,
icon: this.normalizeItemIcon(fields.Icon),
isUntradable: fields.IsUntradable,
itemId,
itemLevel: fields.LevelItem,
itemLevel: this.normalizeItemLevel(fields.LevelItem),
name: displayName || fields.Name || `${itemId}`,
};
}
@ -294,12 +275,107 @@ export class QqbotFf14ClientService {
}
private normalizeXivapiLanguage(language?: string) {
const value = `${language || 'en'}`.trim().toLowerCase();
const value = `${language || 'chs'}`.trim().toLowerCase();
if (['zh', 'zh-cn', 'zh_hans', 'cn', 'chs'].includes(value)) return 'chs';
return ['en', 'ja', 'de', 'fr'].includes(value) ? value : 'en';
}
private resolveCnItemAlias(keyword: string) {
return this.cnItemAliases.get(keyword.trim());
private buildXivapiUrl(path: string, language: string) {
const baseUrl =
language === 'chs' ? this.xivapiChsBaseUrl : this.xivapiBaseUrl;
return new URL(`${baseUrl.replace(/\/+$/, '')}${path}`);
}
private async searchItem(keyword: string, language: string) {
const item = this.pickFirstSearchItem(
await this.searchItemsByLanguage(keyword, language, '='),
);
if (item) return item;
if (language !== 'en') {
const enItem = this.pickFirstSearchItem(
await this.searchItemsByLanguage(keyword, 'en', '='),
);
if (enItem) return enItem;
}
const fuzzyItems = await this.searchItemsByLanguage(keyword, language, '~');
const fuzzyItem = this.pickSingleFuzzySearchItem(keyword, fuzzyItems);
if (fuzzyItem || language === 'en') return fuzzyItem;
const enFuzzyItems = await this.searchItemsByLanguage(keyword, 'en', '~');
return this.pickSingleFuzzySearchItem(keyword, enFuzzyItems);
}
private async searchItemsByLanguage(
keyword: string,
language: string,
operator: '=' | '~',
) {
const url = this.buildXivapiUrl('/search', language);
url.searchParams.set('sheets', 'Item');
url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable');
url.searchParams.set(
'query',
`Name${operator}"${this.escapeXivapiValue(keyword)}"`,
);
url.searchParams.set('language', language);
url.searchParams.set('limit', '10');
const data = await this.requestJson<{ results?: XivapiSearchItem[] }>(
url,
'GET',
'XIVAPI 物品解析',
);
return (data.results || []).filter(
(result) =>
result.sheet === 'Item' || result.fields?.Name || result.name,
);
}
private pickFirstSearchItem(items: XivapiSearchItem[]) {
return items[0];
}
private pickSingleFuzzySearchItem(
keyword: string,
items: XivapiSearchItem[],
) {
if (items.length <= 1) return items[0];
throw new Error(
`找到多个相似物品,请输入更完整名称或物品 ID${this.formatSearchCandidates(
items,
)}`,
);
}
private formatSearchCandidates(items: XivapiSearchItem[]) {
return items
.slice(0, 5)
.map((item) => {
const name = item.fields?.Name || item.name || '未知物品';
const id = item.row_id || item.id;
return id ? `${name}(ID:${id})` : name;
})
.join('、');
}
private normalizeItemIcon(icon: unknown) {
if (typeof icon === 'string') return icon;
if (icon && typeof icon === 'object') {
const item = icon as { path?: string; path_hr1?: string };
return item.path_hr1 || item.path;
}
return undefined;
}
private normalizeItemLevel(level: unknown) {
if (typeof level === 'number') return level;
if (level && typeof level === 'object') {
const item = level as { row_id?: number; value?: number };
return item.row_id ?? item.value;
}
return undefined;
}
private escapeXivapiValue(value: string) {

View File

@ -38,7 +38,7 @@ export class QqbotFf14MarketPluginService {
properties: {
item: { description: '物品名称或 ID', type: 'string' },
itemId: { description: '物品 ID', type: 'number' },
language: { default: 'zh', type: 'string' },
language: { default: 'chs', type: 'string' },
},
required: ['item'],
type: 'object',
@ -64,7 +64,7 @@ export class QqbotFf14MarketPluginService {
hq: { description: '是否只查 HQ', type: 'boolean' },
item: { description: '物品名称或 ID', type: 'string' },
itemId: { description: '物品 ID', type: 'number' },
language: { default: 'zh', type: 'string' },
language: { default: 'chs', type: 'string' },
region: { description: '地区名,如中国', type: 'string' },
world: { description: '小区/服务器名,如红玉海', type: 'string' },
},