feat: 扩展 QQBot FF14 查价区服解析

This commit is contained in:
sunlei 2026-06-02 15:14:38 +08:00
parent 31119bbc93
commit 92462ae736
4 changed files with 407 additions and 26 deletions

View File

@ -1,6 +1,13 @@
import { Injectable } from '@nestjs/common';
import type { QqbotCommand } from './qqbot-command.entity';
import type { QqbotNormalizedMessage } from '../qqbot.types';
import {
isQqbotFf14DataCenterName,
isQqbotFf14LocationName,
isQqbotFf14RegionName,
isQqbotFf14WorldName,
splitQqbotFf14WorldPath,
} from '../plugins/ff14Market/qqbot-ff14-worlds';
export type QqbotCommandMatchResult = {
alias: string;
@ -68,9 +75,9 @@ export class QqbotCommandParserService {
const positional: string[] = [];
for (const token of tokens) {
if (/^(hq|HQ)$/.test(token)) {
if (/^hq$/i.test(token)) {
flags.set('hq', true);
} else if (/^(nq|NQ)$/.test(token)) {
} else if (/^nq$/i.test(token)) {
flags.set('hq', 'false');
} else if (token.includes('=')) {
const [key, ...rest] = token.split('=');
@ -80,27 +87,105 @@ export class QqbotCommandParserService {
}
}
let world = this.normalizeString(flags.get('world') || flags.get('server'));
let region = this.normalizeString(flags.get('region') || flags.get('地区'));
let dataCenter = this.normalizeString(
flags.get('dataCenter') ||
flags.get('datacenter') ||
flags.get('dc') ||
flags.get('大区'),
);
let world = this.normalizeString(
flags.get('world') ||
flags.get('server') ||
flags.get('服务器') ||
flags.get('小区'),
);
let item = positional.join(' ');
if (!world && positional.length > 1) {
world = positional[positional.length - 1];
item = positional.slice(0, -1).join(' ');
const worldPath = splitQqbotFf14WorldPath(world);
if (worldPath.dataCenter && worldPath.world) {
dataCenter = dataCenter || worldPath.dataCenter;
region = region || worldPath.region || '';
world = worldPath.world;
}
if (!world && !dataCenter && positional.length > 1) {
const picked = this.pickTrailingFf14Location(positional);
if (picked) {
dataCenter = picked.dataCenter || dataCenter;
item = picked.item;
region = picked.region || region;
world = picked.world || world;
}
}
if (item.includes('@')) {
const [itemName, worldName] = item.split('@');
const itemWorldPath = splitQqbotFf14WorldPath(worldName);
item = itemName.trim();
world = world || worldName?.trim();
dataCenter = dataCenter || itemWorldPath.dataCenter || '';
region = region || itemWorldPath.region || '';
world = world || itemWorldPath.world || worldName?.trim();
}
return {
dataCenter,
hq: this.normalizeHq(flags.get('hq')),
item,
language: this.normalizeString(flags.get('lang')) || 'zh',
raw: rawArgs,
region,
world,
};
}
private pickTrailingFf14Location(positional: string[]) {
const last = positional[positional.length - 1];
if (!isQqbotFf14LocationName(last)) return null;
const path = splitQqbotFf14WorldPath(last);
if (path.dataCenter && path.world) {
return {
dataCenter: path.dataCenter,
item: positional.slice(0, -1).join(' '),
region: path.region,
world: path.world,
};
}
const previous = positional[positional.length - 2];
const beforePrevious = positional[positional.length - 3];
if (
previous &&
isQqbotFf14DataCenterName(previous) &&
isQqbotFf14WorldName(last)
) {
const hasRegion = beforePrevious && isQqbotFf14RegionName(beforePrevious);
return {
dataCenter: previous,
item: positional.slice(0, hasRegion ? -3 : -2).join(' '),
region: hasRegion ? beforePrevious : undefined,
world: last,
};
}
if (
previous &&
isQqbotFf14RegionName(previous) &&
isQqbotFf14DataCenterName(last)
) {
return {
dataCenter: last,
item: positional.slice(0, -2).join(' '),
region: previous,
};
}
return {
item: positional.slice(0, -1).join(' '),
world: last,
};
}
private normalizeHq(value?: string | true) {
if (value === undefined) return undefined;
if (value === true) return true;

View File

@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as http from 'node:http';
import * as https from 'node:https';
import { resolveQqbotFf14MarketTarget } from './qqbot-ff14-worlds';
type HttpMethod = 'GET';
type XivapiSearchItem = {
fields?: {
Icon?: string;
IsUntradable?: boolean;
LevelItem?: number;
Name?: string;
};
@ -40,6 +42,7 @@ type UniversalisMarketResponse = {
export type QqbotFf14ResolvedItem = {
icon?: string;
isUntradable?: boolean;
itemId: number;
itemLevel?: number;
name: string;
@ -60,6 +63,13 @@ export type QqbotFf14PriceResult = {
export class QqbotFf14ClientService {
private readonly xivapiBaseUrl: 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 =
@ -75,22 +85,31 @@ export class QqbotFf14ClientService {
itemId?: number | string;
language?: string;
}): Promise<QqbotFf14ResolvedItem> {
const language = this.normalizeXivapiLanguage(params.language);
const itemId = Number(params.itemId || params.item);
if (Number.isInteger(itemId) && itemId > 0) {
return this.getItemById(itemId, params.language);
return this.getItemById(itemId, language);
}
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('query', keyword);
url.searchParams.set('language', params.language || 'zh');
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) =>
@ -100,6 +119,7 @@ export class QqbotFf14ClientService {
return {
icon: item.fields?.Icon,
isUntradable: item.fields?.IsUntradable,
itemId: Number(item.row_id || item.id),
itemLevel: item.fields?.LevelItem,
name: item.fields?.Name || item.name || keyword,
@ -107,23 +127,49 @@ export class QqbotFf14ClientService {
}
async getPrice(params: {
dataCenter?: string;
hq?: boolean;
item?: string;
itemId?: number | string;
language?: string;
region?: string;
world?: string;
}): Promise<QqbotFf14PriceResult> {
const world = this.normalizeWorld(params.world);
const marketTarget = this.resolveMarketTarget(params);
const item = await this.resolveItem(params);
const url = new URL(`${this.universalisBaseUrl}/${world}/${item.itemId}`);
if (item.isUntradable) {
return {
hq: params.hq,
item,
listings: [],
replyText: `FF14 查价:${item.name}\n该物品不可交易暂无市场价格。`,
world: marketTarget.label,
};
}
const url = new URL(
`${this.universalisBaseUrl}/${encodeURIComponent(
marketTarget.target,
)}/${item.itemId}`,
);
url.searchParams.set('entries', '5');
url.searchParams.set('listings', '5');
if (params.hq !== undefined) url.searchParams.set('hq', `${params.hq}`);
const data = await this.requestJson<UniversalisMarketResponse>(url, 'GET');
const minPrice = this.pickPrice(data, params.hq, 'min');
const averagePrice = this.pickPrice(data, params.hq, 'average');
const data = await this.requestJson<UniversalisMarketResponse>(
url,
'GET',
'Universalis 市场查询',
);
const listings = (data.listings || []).slice(0, 5);
const minPrice = this.normalizeMarketPrice(
this.pickPrice(data, params.hq, 'min'),
listings,
);
const averagePrice = this.normalizeMarketPrice(
this.pickPrice(data, params.hq, 'average'),
listings,
);
const updatedAt = data.lastUploadTime
? new Date(data.lastUploadTime).toISOString()
: undefined;
@ -141,26 +187,33 @@ export class QqbotFf14ClientService {
listings,
minPrice,
updatedAt,
world,
world: marketTarget.label,
}),
updatedAt,
world,
world: marketTarget.label,
};
}
private async getItemById(
itemId: number,
language = 'zh',
displayName?: string,
): Promise<QqbotFf14ResolvedItem> {
const url = new URL(`${this.xivapiBaseUrl}/sheet/Item/${itemId}`);
url.searchParams.set('language', language);
const data = await this.requestJson<Record<string, any>>(url, 'GET');
url.searchParams.set('fields', 'Name,Icon,LevelItem,IsUntradable');
url.searchParams.set('language', this.normalizeXivapiLanguage(language));
const data = await this.requestJson<Record<string, any>>(
url,
'GET',
'XIVAPI 物品解析',
);
const fields = data.fields || data;
return {
icon: fields.Icon,
isUntradable: fields.IsUntradable,
itemId,
itemLevel: fields.LevelItem,
name: fields.Name || `${itemId}`,
name: displayName || fields.Name || `${itemId}`,
};
}
@ -182,7 +235,7 @@ export class QqbotFf14ClientService {
return [
`FF14 查价:${result.item.name}${quality}`,
`服务器${result.world}`,
`查询范围${result.world}`,
`最低价:${minPrice}`,
`均价:${averagePrice}`,
`近期挂单:`,
@ -209,13 +262,47 @@ export class QqbotFf14ClientService {
);
}
private normalizeMarketPrice(
price: number | undefined,
listings: UniversalisListing[],
) {
if (!listings.length && (!price || price <= 0)) return undefined;
return price;
}
private normalizeWorld(world?: string) {
const raw =
`${world || this.configService.get<string>('FF14_DEFAULT_WORLD') || '中国'}`.trim();
return encodeURIComponent(raw);
return raw;
}
private requestJson<T>(url: URL, method: HttpMethod) {
private resolveMarketTarget(params: {
dataCenter?: string;
region?: string;
world?: string;
}) {
return resolveQqbotFf14MarketTarget({
dataCenter: params.dataCenter,
fallback: this.normalizeWorld(params.world),
region: params.region,
world: params.world,
});
}
private normalizeXivapiLanguage(language?: string) {
const value = `${language || 'en'}`.trim().toLowerCase();
return ['en', 'ja', 'de', 'fr'].includes(value) ? value : 'en';
}
private resolveCnItemAlias(keyword: string) {
return this.cnItemAliases.get(keyword.trim());
}
private escapeXivapiValue(value: string) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
private requestJson<T>(url: URL, method: HttpMethod, context: string) {
return new Promise<T>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https;
const request = client.request(
@ -236,7 +323,9 @@ export class QqbotFf14ClientService {
});
response.on('end', () => {
if ((response.statusCode || 500) >= 400) {
reject(new Error(`FF14 接口请求失败:${response.statusCode}`));
reject(
new Error(`${context}失败:${response.statusCode}`),
);
return;
}
try {

View File

@ -60,13 +60,15 @@ export class QqbotFf14MarketPluginService {
description: '查询指定服务器的 FF14 市场最低价、均价与近期挂单。',
inputSchema: {
properties: {
dataCenter: { description: '大区名,如陆行鸟', type: 'string' },
hq: { description: '是否只查 HQ', type: 'boolean' },
item: { description: '物品名称或 ID', type: 'string' },
itemId: { description: '物品 ID', type: 'number' },
language: { default: 'zh', type: 'string' },
world: { description: '服务器名或大区名', type: 'string' },
region: { description: '地区名,如中国', type: 'string' },
world: { description: '小区/服务器名,如红玉海', type: 'string' },
},
required: ['item', 'world'],
required: ['item'],
type: 'object',
},
key: 'ff14.market.price',

View File

@ -0,0 +1,205 @@
export type QqbotFf14DataCenter = {
name: string;
region: string;
worlds: string[];
};
export type QqbotFf14MarketTarget = {
dataCenter?: string;
label: string;
region?: string;
target: string;
world?: string;
};
export const QQBOT_FF14_DEFAULT_REGION = '中国';
export const QQBOT_FF14_CHINA_DATA_CENTERS: QqbotFf14DataCenter[] = [
{
name: '陆行鸟',
region: QQBOT_FF14_DEFAULT_REGION,
worlds: [
'红玉海',
'神意之地',
'拉诺西亚',
'幻影群岛',
'萌芽池',
'宇宙和音',
'沃仙曦染',
'晨曦王座',
],
},
{
name: '莫古力',
region: QQBOT_FF14_DEFAULT_REGION,
worlds: [
'白银乡',
'白金幻象',
'神拳痕',
'潮风亭',
'旅人栈桥',
'拂晓之间',
'龙巢神殿',
'梦羽宝境',
],
},
{
name: '猫小胖',
region: QQBOT_FF14_DEFAULT_REGION,
worlds: [
'紫水栈桥',
'延夏',
'静语庄园',
'摩杜纳',
'海猫茶屋',
'柔风海湾',
'琥珀原',
],
},
{
name: '豆豆柴',
region: QQBOT_FF14_DEFAULT_REGION,
worlds: ['水晶塔', '银泪湖', '太阳海岸', '伊修加德', '红茶川'],
},
];
export function isQqbotFf14DataCenterName(value?: string) {
const name = normalizeQqbotFf14WorldValue(value);
return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => item.name === name);
}
export function isQqbotFf14RegionName(value?: string) {
return normalizeQqbotFf14WorldValue(value) === QQBOT_FF14_DEFAULT_REGION;
}
export function isQqbotFf14WorldName(value?: string) {
const name = normalizeQqbotFf14WorldValue(value);
return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) =>
item.worlds.includes(name),
);
}
export function isQqbotFf14LocationName(value?: string) {
const name = normalizeQqbotFf14WorldValue(value);
const path = splitQqbotFf14WorldPath(name);
return (
isQqbotFf14RegionName(name) ||
isQqbotFf14DataCenterName(name) ||
isQqbotFf14WorldName(name) ||
(!!path.dataCenter && !!path.world)
);
}
export function splitQqbotFf14WorldPath(value?: string) {
const raw = normalizeQqbotFf14WorldValue(value);
if (!raw) return {};
const parts = raw
.split(/\s*(?:->|=>|>|\/|\\||:)\s*/)
.map((item) => item.trim())
.filter(Boolean);
if (parts.length < 2) return {};
if (parts.length === 2) {
return {
dataCenter: parts[0],
world: parts[1],
};
}
return {
dataCenter: parts[parts.length - 2],
region: parts[0],
world: parts[parts.length - 1],
};
}
export function findQqbotFf14DataCenterByWorld(world?: string) {
const worldName = normalizeQqbotFf14WorldValue(world);
return QQBOT_FF14_CHINA_DATA_CENTERS.find((item) =>
item.worlds.includes(worldName),
);
}
export function resolveQqbotFf14MarketTarget(params: {
dataCenter?: string;
fallback?: string;
region?: string;
world?: string;
}): QqbotFf14MarketTarget {
const fallback = normalizeQqbotFf14WorldValue(params.fallback);
const path = splitQqbotFf14WorldPath(params.world);
const region = normalizeQqbotFf14WorldValue(params.region || path.region);
const dataCenter = normalizeQqbotFf14WorldValue(
params.dataCenter || path.dataCenter,
);
const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world);
const world =
dataCenter && rawWorld === QQBOT_FF14_DEFAULT_REGION ? '' : rawWorld;
const raw =
world || dataCenter || region || fallback || QQBOT_FF14_DEFAULT_REGION;
if (region && dataCenter && (!world || world === region)) {
return {
dataCenter,
label: `${region} / ${dataCenter}`,
region,
target: dataCenter,
};
}
if (raw === QQBOT_FF14_DEFAULT_REGION) {
return {
label: QQBOT_FF14_DEFAULT_REGION,
region: QQBOT_FF14_DEFAULT_REGION,
target: QQBOT_FF14_DEFAULT_REGION,
};
}
if (dataCenter && world && world !== dataCenter) {
const matchedDataCenter = QQBOT_FF14_CHINA_DATA_CENTERS.find(
(item) => item.name === dataCenter,
);
if (matchedDataCenter && !matchedDataCenter.worlds.includes(world)) {
throw new Error(`服务器 ${world} 不属于大区 ${dataCenter}`);
}
return {
dataCenter,
label: region
? `${region} / ${dataCenter} / ${world}`
: `${dataCenter} / ${world}`,
region,
target: world,
world,
};
}
const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(raw);
if (matchedWorldDataCenter) {
return {
dataCenter: matchedWorldDataCenter.name,
label: `${matchedWorldDataCenter.region} / ${matchedWorldDataCenter.name} / ${raw}`,
region: matchedWorldDataCenter.region,
target: raw,
world: raw,
};
}
if (isQqbotFf14DataCenterName(raw)) {
return {
dataCenter: raw,
label: `${QQBOT_FF14_DEFAULT_REGION} / ${raw}`,
region: QQBOT_FF14_DEFAULT_REGION,
target: raw,
};
}
return {
label: raw,
target: raw,
};
}
function normalizeQqbotFf14WorldValue(value?: string) {
return `${value || ''}`.trim();
}