refactor: 收口 BangDream 搜索和活动底图
This commit is contained in:
parent
4b3a2bf3b6
commit
9f0293a977
@ -291,7 +291,9 @@ export interface TsuguHook {
|
||||
- `command-renderers/song-list.ts`、`event-list.ts`、`card-list.ts`、`gacha-detail.ts`、`player-detail.ts`、`cutoff-detail.ts` 已开始改走 repository 创建模型或读取主数据。
|
||||
- `models/song.ts`、`card.ts`、`event.ts`、`gacha.ts`、`player.ts` 的 Bestdori API/素材请求已开始改走 Bestdori provider;`models/cutoff.ts` 的 Bestdori/HHWX Tracker fallback 已改走 provider,并在 fallback 时输出数据源切换日志。
|
||||
- 已新增 `models/event-data-repository.ts` 收口活动详情、横幅、背景、轮播、Logo、奖励表情和装饰素材请求;`models/event.ts` 不再直接依赖 Bestdori provider 或 `loadImage`。
|
||||
- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。
|
||||
- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动底图由 `bg_eventtop.png` 和 `trim_eventtop.png` 合成,避免只渲染模糊背景底图;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。
|
||||
- 已接入 `BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS` 和 `BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS`,HTTP 请求和主数据首次 ready 等待都有硬超时,BangDream 命令执行前会等待关键主数据集合可用。
|
||||
- 已新增 `scripts/bangdream-render-smoke.ps1`,BangDream 图片 smoke 通过父进程限时、子进程 PID 清理、生成后显式退出,避免本地调试命令卡住进程。
|
||||
- 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。
|
||||
- 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。
|
||||
|
||||
@ -312,6 +314,13 @@ export interface TsuguHook {
|
||||
- 新增昵称或难度别名无需改 matcher 代码。
|
||||
- 旧 search/fuzzy-search 测试全部通过。
|
||||
|
||||
当前进度:
|
||||
|
||||
- 已新增 `search/fuzzy-search-types.ts`、`search/search-dictionary-repository.ts`、`search/relation-matcher.ts`、`search/fuzzy-search-rule-registry.ts`,把搜索类型、搜索字典读取、关系表达式和规则注册表从 `fuzzy-search.ts` 中拆出。
|
||||
- `fuzzy-search.ts` 保留关键词拆分、结果校验和目标匹配的兼容入口,关键词解析改由 `FuzzySearchRuleRegistry` 按 number、level、relation、config、fallback 顺序处理,文件长度从 516 行降到 281 行。
|
||||
- `entity-list-matcher.ts` 改为只依赖搜索结果类型和关系匹配器,并支持延迟读取 `source`,修复 mainAPI 异步加载前捕获空数据源导致查歌/查卡/查活动搜索不到的问题。
|
||||
- `song-list.ts`、`card-list.ts`、`event-list.ts` 的实体列表匹配器已改为运行时读取 repository source,查歌 `夏祭り` 和查活动 `summer` 本地图片 smoke 均生成非空图片。
|
||||
|
||||
### Phase 5:渲染 theme、layout spec 和 section builder
|
||||
|
||||
目标:Canvas 绘制仍保留函数式性能,但颜色、尺寸、字体和区块顺序收口。
|
||||
|
||||
90
scripts/bangdream-render-smoke.ps1
Normal file
90
scripts/bangdream-render-smoke.ps1
Normal file
@ -0,0 +1,90 @@
|
||||
param(
|
||||
[string]$OperationKey = "bangdream.event.search",
|
||||
[string]$Text = "50",
|
||||
[string]$DisplayedServerList = "cn jp",
|
||||
[string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg",
|
||||
[int]$TimeoutSeconds = 45,
|
||||
[switch]$UseEasyBg
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$ResolvedOutFile = if ([System.IO.Path]::IsPathRooted($OutFile)) {
|
||||
$OutFile
|
||||
} else {
|
||||
Join-Path $ProjectRoot $OutFile
|
||||
}
|
||||
$OutDir = Split-Path -Parent $ResolvedOutFile
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
$LogDir = Join-Path $ProjectRoot ".kt-workspace/bangdream-smoke"
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
$LogName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedOutFile)
|
||||
$StdoutLog = Join-Path $LogDir "$LogName.out.log"
|
||||
$StderrLog = Join-Path $LogDir "$LogName.err.log"
|
||||
Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$Payload = @{
|
||||
input = @{
|
||||
compress = $true
|
||||
displayedServerList = $DisplayedServerList
|
||||
text = $Text
|
||||
useEasyBG = [bool]$UseEasyBg
|
||||
}
|
||||
operationKey = $OperationKey
|
||||
outFile = $ResolvedOutFile
|
||||
}
|
||||
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
|
||||
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
|
||||
|
||||
$NodeCode = @"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ConfigService } = require("@nestjs/config");
|
||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||
const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service");
|
||||
|
||||
(async () => {
|
||||
const service = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
|
||||
await service.onApplicationBootstrap();
|
||||
const result = await service.execute(payload.operationKey, payload.input);
|
||||
const match = result.replyText.match(/base64:\/\/([A-Za-z0-9+/=]+)/);
|
||||
if (!match) throw new Error("No image CQ payload");
|
||||
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
|
||||
fs.writeFileSync(payload.outFile, Buffer.from(match[1], "base64"));
|
||||
process.stdout.write(JSON.stringify({
|
||||
bytes: fs.statSync(payload.outFile).size,
|
||||
imageCount: result.imageCount,
|
||||
out: payload.outFile,
|
||||
}, null, 2) + "\n");
|
||||
process.exit(0);
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack || error) + "\n");
|
||||
process.exit(1);
|
||||
});
|
||||
"@
|
||||
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
|
||||
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
|
||||
|
||||
$env:TS_NODE_TRANSPILE_ONLY = "true"
|
||||
$Process = Start-Process `
|
||||
-FilePath "node" `
|
||||
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
|
||||
-WorkingDirectory $ProjectRoot `
|
||||
-RedirectStandardOutput $StdoutLog `
|
||||
-RedirectStandardError $StderrLog `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
|
||||
if (-not $Process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "BangDream smoke timed out and process $($Process.Id) was killed."
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
exit 124
|
||||
}
|
||||
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
exit $Process.ExitCode
|
||||
@ -7,7 +7,7 @@ import { getPresentEvent } from '../tsugu/models/event';
|
||||
import { Gacha, getPresentGachaList } from '../tsugu/models/gacha';
|
||||
import { Server } from '../tsugu/models/server';
|
||||
import { Song } from '../tsugu/models/song';
|
||||
import mainAPI from '../tsugu/models/main-data-store';
|
||||
import mainAPI, { waitForMainDataReady } from '../tsugu/models/main-data-store';
|
||||
import { fuzzySearch, type FuzzySearchResult } from '../tsugu/search/fuzzy-search';
|
||||
import { drawCardDetail } from '../tsugu/command-renderers/card-detail';
|
||||
import { drawCardList } from '../tsugu/command-renderers/card-list';
|
||||
@ -66,7 +66,8 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
checkHealth() {
|
||||
async checkHealth() {
|
||||
await waitForMainDataReady();
|
||||
const data = mainAPI as { cards?: unknown; songs?: unknown };
|
||||
if (!data.songs || !data.cards) {
|
||||
throw new Error('Tsugu 数据配置未加载');
|
||||
@ -79,6 +80,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
|
||||
operationKey: QqbotBangDreamOperationKey,
|
||||
input: QqbotBangDreamCommandInput,
|
||||
) {
|
||||
await waitForMainDataReady();
|
||||
const operation = getBangDreamOperationDefinition(operationKey);
|
||||
if (!operation) {
|
||||
throw new Error(`BangDream 插件能力不存在:${operationKey}`);
|
||||
|
||||
@ -63,7 +63,7 @@ export async function drawCardList(
|
||||
|
||||
//计算模糊搜索结果
|
||||
export const matchCardList = createTsuguEntityMatcher<Card>({
|
||||
source: cardRepository.getSource(),
|
||||
source: () => cardRepository.getSource(),
|
||||
/**
|
||||
* 在QQBot 图片视图层中创建Entity。
|
||||
*
|
||||
|
||||
@ -144,7 +144,7 @@ export async function drawEventList(
|
||||
}
|
||||
|
||||
const matchEventList = createTsuguEntityMatcher<Event>({
|
||||
source: eventRepository.getSource(),
|
||||
source: () => eventRepository.getSource(),
|
||||
/**
|
||||
* 在QQBot 图片视图层中创建Entity。
|
||||
*
|
||||
|
||||
@ -118,7 +118,7 @@ export async function drawSongList(
|
||||
|
||||
// 计算歌曲模糊搜索结果
|
||||
export const matchSongList = createTsuguEntityMatcher<Song>({
|
||||
source: songRepository.getSource(),
|
||||
source: () => songRepository.getSource(),
|
||||
/**
|
||||
* 在QQBot 图片视图层中创建Entity。
|
||||
*
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
import axios from 'axios';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
BANGDREAM_TSUGU_ENV_KEYS,
|
||||
normalizeBangDreamPositiveInteger,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options';
|
||||
|
||||
const errUrl: string[] = [];
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
function getRequestTimeoutMs(): number {
|
||||
return normalizeBangDreamPositiveInteger(
|
||||
process.env[BANGDREAM_TSUGU_ENV_KEYS.requestTimeoutMs],
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在数据下载与缓存层中下载当前数据。
|
||||
@ -45,7 +57,11 @@ export async function download(
|
||||
const headers = eTag ? { 'If-None-Match': eTag } : {};
|
||||
let response;
|
||||
try {
|
||||
response = await axios.get(url, { headers, responseType: 'arraybuffer' });
|
||||
response = await axios.get(url, {
|
||||
headers,
|
||||
responseType: 'arraybuffer',
|
||||
timeout: getRequestTimeoutMs(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 304) {
|
||||
const cachedData = fs.readFileSync(cacheFilePath);
|
||||
@ -130,7 +146,11 @@ export async function getJsonAndSave(
|
||||
const headers = eTag ? { 'If-None-Match': eTag } : {};
|
||||
let response;
|
||||
try {
|
||||
response = await axios.get(url, { headers, responseType: 'arraybuffer' });
|
||||
response = await axios.get(url, {
|
||||
headers,
|
||||
responseType: 'arraybuffer',
|
||||
timeout: getRequestTimeoutMs(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 304) {
|
||||
const cachedData = fs.readFileSync(cacheFilePath, 'utf-8');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Image, loadImage } from 'skia-canvas';
|
||||
import { Canvas, Image, loadImage } from 'skia-canvas';
|
||||
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
|
||||
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
|
||||
import {
|
||||
@ -80,12 +80,18 @@ class EventDataRepository {
|
||||
*
|
||||
* @param event - 活动资源上下文。
|
||||
*/
|
||||
async getBackgroundImage(event: EventAssetContext): Promise<Image> {
|
||||
async getBackgroundImage(event: EventAssetContext): Promise<Image | Canvas> {
|
||||
const server = getServerByPriority(event.startAt);
|
||||
const bgImageBuffer = await bangDreamBestdoriProvider.getAsset(
|
||||
`/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/bg_eventtop.png`,
|
||||
);
|
||||
return await loadImage(bgImageBuffer);
|
||||
const backgroundImage = await loadImage(bgImageBuffer);
|
||||
try {
|
||||
const trimImage = await this.getTopscreenTrimImage(event);
|
||||
return this.mergeTopscreenImages(backgroundImage, trimImage);
|
||||
} catch {
|
||||
return backgroundImage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -130,6 +136,31 @@ class EventDataRepository {
|
||||
return await loadImage(topscreenTrimImageBuffer);
|
||||
}
|
||||
|
||||
private mergeTopscreenImages(
|
||||
backgroundImage: Image,
|
||||
trimImage: Image,
|
||||
): Canvas {
|
||||
const canvas = new Canvas(backgroundImage.width, backgroundImage.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(backgroundImage, 0, 0);
|
||||
|
||||
const scale = Math.min(
|
||||
backgroundImage.width / trimImage.width,
|
||||
backgroundImage.height / trimImage.height,
|
||||
1,
|
||||
);
|
||||
const width = trimImage.width * scale;
|
||||
const height = trimImage.height * scale;
|
||||
ctx.drawImage(
|
||||
trimImage,
|
||||
(backgroundImage.width - width) / 2,
|
||||
backgroundImage.height - height,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动 Logo 图。
|
||||
*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Image } from 'skia-canvas';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
|
||||
import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute';
|
||||
@ -213,7 +213,7 @@ export class Event {
|
||||
*
|
||||
* @returns 异步处理结果。
|
||||
*/
|
||||
async getEventBGImage(): Promise<Image> {
|
||||
async getEventBGImage(): Promise<Image | Canvas> {
|
||||
return await eventDataRepository.getBackgroundImage(this);
|
||||
}
|
||||
//活动规则轮播图
|
||||
|
||||
@ -2,8 +2,33 @@ import { bestdoriApiPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'
|
||||
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
|
||||
import { bangDreamStaticPatchProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/static-patch-provider';
|
||||
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger';
|
||||
import {
|
||||
BANGDREAM_TSUGU_ENV_KEYS,
|
||||
normalizeBangDreamPositiveInteger,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options';
|
||||
|
||||
const mainAPI: Record<string, any> = {}; //main对象,用于存放所有api数据,数据来源于Bestdori网站
|
||||
const REQUIRED_MAIN_DATA_KEYS = ['cards', 'characters', 'events', 'gacha', 'songs'];
|
||||
const DEFAULT_MAIN_DATA_READY_TIMEOUT_MS = 15000;
|
||||
|
||||
function getMainDataReadyTimeoutMs(): number {
|
||||
return normalizeBangDreamPositiveInteger(
|
||||
process.env[BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs],
|
||||
DEFAULT_MAIN_DATA_READY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function isMainDataReady(): boolean {
|
||||
return REQUIRED_MAIN_DATA_KEYS.every((key) => {
|
||||
const collection = mainAPI[key];
|
||||
return collection && Object.keys(collection).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function rejectAfter(ms: number): Promise<never> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
throw new Error(`BangDream 主数据首次加载超时:${ms}ms`);
|
||||
}
|
||||
|
||||
//加载mainAPI
|
||||
/**
|
||||
@ -71,11 +96,24 @@ async function loadMainAPI(useCache: boolean = false) {
|
||||
}
|
||||
|
||||
logger('mainAPI', 'initializing...');
|
||||
loadMainAPI(true).then(() => {
|
||||
const initialLoadPromise = loadMainAPI(true).then(() => {
|
||||
logger('mainAPI', 'initializing done');
|
||||
loadMainAPI();
|
||||
});
|
||||
|
||||
setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次
|
||||
|
||||
/**
|
||||
* 等待 BangDream 主数据完成首次加载。
|
||||
*/
|
||||
export async function waitForMainDataReady(): Promise<void> {
|
||||
if (isMainDataReady()) {
|
||||
return;
|
||||
}
|
||||
await Promise.race([initialLoadPromise, rejectAfter(getMainDataReadyTimeoutMs())]);
|
||||
if (!isMainDataReady()) {
|
||||
throw new Error('BangDream 主数据未完成关键集合加载');
|
||||
}
|
||||
}
|
||||
|
||||
export default mainAPI;
|
||||
|
||||
@ -11,6 +11,7 @@ export const BANGDREAM_TSUGU_ENV_KEYS = {
|
||||
compress: 'BANGDREAM_TSUGU_COMPRESS',
|
||||
displayedServers: 'BANGDREAM_TSUGU_DISPLAYED_SERVERS',
|
||||
hhwxBaseUrl: 'BANGDREAM_TSUGU_HHWX_BASE_URL',
|
||||
mainDataReadyTimeoutMs: 'BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS',
|
||||
mainServer: 'BANGDREAM_TSUGU_MAIN_SERVER',
|
||||
requestTimeoutMs: 'BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS',
|
||||
retryCount: 'BANGDREAM_TSUGU_RETRY_COUNT',
|
||||
@ -97,6 +98,14 @@ export const BANGDREAM_CN_ESTIMATE_START_EVENT_ID = 298;
|
||||
export const BANGDREAM_CN_BLOCKED_EVENT_IDS: readonly number[] = [];
|
||||
export const BANGDREAM_DEFAULT_NO_BANG_DAYS = 1;
|
||||
|
||||
export function normalizeBangDreamPositiveInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeBangDreamBoolean(
|
||||
value: unknown,
|
||||
fallback: boolean,
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import type { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import {
|
||||
checkRelationList,
|
||||
FuzzySearchResult,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search';
|
||||
import { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher';
|
||||
import type { FuzzySearchResult } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
|
||||
interface TsuguEntityMatcherOptions<T> {
|
||||
source: Record<string, unknown>;
|
||||
source: Record<string, unknown> | (() => Record<string, unknown>);
|
||||
createEntity: (id: number) => T;
|
||||
isCandidate?: (entity: T) => boolean;
|
||||
isReleased: (entity: T, displayedServerList: Server[]) => boolean;
|
||||
@ -49,6 +47,15 @@ const getRelationList = (matches: FuzzySearchResult): string[] | undefined =>
|
||||
? (matches._relationStr as string[])
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* 在QQBot 图片视图层中获取当前实体源。
|
||||
*
|
||||
* @param source - 静态实体源或延迟读取函数。
|
||||
*/
|
||||
const getCurrentSource = (
|
||||
source: TsuguEntityMatcherOptions<unknown>['source'],
|
||||
) => (typeof source === 'function' ? source() : source);
|
||||
|
||||
/**
|
||||
* 在QQBot 图片视图层中判断是否需要Check关系表达式。
|
||||
*
|
||||
@ -76,13 +83,14 @@ export const createTsuguEntityMatcher =
|
||||
}: TsuguEntityMatcherOptions<T>) =>
|
||||
(matches: FuzzySearchResult, displayedServerList: Server[]): T[] => {
|
||||
const result: T[] = [];
|
||||
const currentSource = getCurrentSource(source);
|
||||
const relationList = getRelationList(matches);
|
||||
const relationOnly =
|
||||
relationList !== undefined && getMatchKeyCount(matches) === 1;
|
||||
const useRelation = shouldCheckRelation(relationList, relationOnly);
|
||||
|
||||
for (const id in source) {
|
||||
if (!hasOwn(source, id)) {
|
||||
for (const id in currentSource) {
|
||||
if (!hasOwn(currentSource, id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,225 @@
|
||||
import {
|
||||
isValidRelationStr,
|
||||
normalizeRelationKeyword,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher';
|
||||
import type {
|
||||
FuzzySearchConfig,
|
||||
FuzzySearchConfigValue,
|
||||
FuzzySearchMatchValue,
|
||||
FuzzySearchResultWriter,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
|
||||
const INTEGER_PATTERN = /^(0|[1-9]\d*)$/;
|
||||
|
||||
export interface FuzzySearchKeyword {
|
||||
lowerKeyword: string;
|
||||
normalizedKeyword: string;
|
||||
rawKeyword: string;
|
||||
}
|
||||
|
||||
export interface FuzzySearchRule {
|
||||
name: string;
|
||||
canHandle: (keyword: FuzzySearchKeyword) => boolean;
|
||||
match: (
|
||||
keyword: FuzzySearchKeyword,
|
||||
push: FuzzySearchResultWriter,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class FuzzySearchRuleRegistry {
|
||||
constructor(private readonly rules: readonly FuzzySearchRule[]) {}
|
||||
|
||||
/**
|
||||
* 按注册顺序匹配并执行第一条可处理规则。
|
||||
*
|
||||
* @param keyword - 结构化关键词。
|
||||
* @param push - 搜索结果写入器。
|
||||
*/
|
||||
match(
|
||||
keyword: FuzzySearchKeyword,
|
||||
push: FuzzySearchResultWriter,
|
||||
): boolean {
|
||||
const rule = this.rules.find((item) => item.canHandle(keyword));
|
||||
if (!rule) return false;
|
||||
rule.match(keyword, push);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建结构化关键词,供规则判断和写入使用。
|
||||
*
|
||||
* @param rawKeyword - 用户输入关键词。
|
||||
*/
|
||||
export function createFuzzySearchKeyword(rawKeyword: string): FuzzySearchKeyword {
|
||||
const lowerKeyword = rawKeyword.toLowerCase();
|
||||
return {
|
||||
lowerKeyword,
|
||||
normalizedKeyword: normalizeRelationKeyword(lowerKeyword),
|
||||
rawKeyword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认模糊搜索规则注册表。
|
||||
*
|
||||
* @param config - 搜索别名配置。
|
||||
*/
|
||||
export function createDefaultFuzzySearchRuleRegistry(
|
||||
config: FuzzySearchConfig,
|
||||
) {
|
||||
return new FuzzySearchRuleRegistry([
|
||||
createNumberRule(),
|
||||
createLevelRule(),
|
||||
createRelationRule(),
|
||||
createConfigRule(config),
|
||||
createFallbackRule(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数字 ID 规则。
|
||||
*/
|
||||
function createNumberRule(): FuzzySearchRule {
|
||||
return {
|
||||
canHandle: ({ lowerKeyword }) => isInteger(lowerKeyword),
|
||||
match: ({ lowerKeyword }, push) =>
|
||||
push('_number')(parseInt(lowerKeyword, 10)),
|
||||
name: 'number',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建等级关键词规则。
|
||||
*/
|
||||
function createLevelRule(): FuzzySearchRule {
|
||||
return {
|
||||
canHandle: ({ normalizedKeyword }) => extractLvNumber(normalizedKeyword) !== null,
|
||||
match: ({ normalizedKeyword }, push) =>
|
||||
push('songLevels')(extractLvNumber(normalizedKeyword) ?? 0),
|
||||
name: 'level',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建关系表达式规则。
|
||||
*/
|
||||
function createRelationRule(): FuzzySearchRule {
|
||||
return {
|
||||
canHandle: ({ normalizedKeyword }) => isValidRelationStr(normalizedKeyword),
|
||||
match: ({ normalizedKeyword }, push) =>
|
||||
push('_relationStr')(normalizedKeyword),
|
||||
name: 'relation',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配置别名规则。
|
||||
*
|
||||
* @param config - 搜索别名配置。
|
||||
*/
|
||||
function createConfigRule(config: FuzzySearchConfig): FuzzySearchRule {
|
||||
return {
|
||||
canHandle: ({ normalizedKeyword }) =>
|
||||
collectConfigMatches(config, normalizedKeyword).length > 0,
|
||||
match: ({ normalizedKeyword }, push) => {
|
||||
for (const item of collectConfigMatches(config, normalizedKeyword)) {
|
||||
push(item.type)(item.value);
|
||||
}
|
||||
},
|
||||
name: 'config',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建兜底全文规则。
|
||||
*/
|
||||
function createFallbackRule(): FuzzySearchRule {
|
||||
return {
|
||||
canHandle: () => true,
|
||||
match: ({ rawKeyword }, push) => push('_all')(rawKeyword),
|
||||
name: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从等级关键词中提取数字等级。
|
||||
*
|
||||
* @param str - 标准化后的关键词。
|
||||
*/
|
||||
function extractLvNumber(str: string): number | null {
|
||||
const match = str.match(/^lv(\d+)$/i);
|
||||
return match?.[1] ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为非负整数。
|
||||
*
|
||||
* @param value - 当前处理的值。
|
||||
*/
|
||||
function isInteger(value: string): boolean {
|
||||
return INTEGER_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断对象是否包含指定自有属性。
|
||||
*
|
||||
* @param source - 输入来源对象或数据集合。
|
||||
* @param key - 当前字段键名。
|
||||
*/
|
||||
const hasOwn = (source: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(source, key);
|
||||
|
||||
/**
|
||||
* 把配置键转换成数字或字符串匹配值。
|
||||
*
|
||||
* @param key - 当前字段键名。
|
||||
*/
|
||||
function parseConfigKey(key: string): FuzzySearchMatchValue {
|
||||
return isInteger(key) ? parseInt(key, 10) : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断配置值是否命中关键词。
|
||||
*
|
||||
* @param value - 当前处理的值。
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
*/
|
||||
function configValueMatches(
|
||||
value: FuzzySearchConfigValue,
|
||||
keyword: string,
|
||||
): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value === keyword;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(keyword);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return hasOwn(value, keyword);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集配置别名命中结果。
|
||||
*
|
||||
* @param config - 搜索别名配置。
|
||||
* @param keyword - 标准化后的关键词。
|
||||
*/
|
||||
function collectConfigMatches(config: FuzzySearchConfig, keyword: string) {
|
||||
const result: Array<{ type: string; value: FuzzySearchMatchValue }> = [];
|
||||
|
||||
for (const type in config) {
|
||||
const typeConfig = config[type];
|
||||
for (const key in typeConfig) {
|
||||
for (const value of typeConfig[key]) {
|
||||
if (configValueMatches(value, keyword)) {
|
||||
result.push({ type, value: parseConfigKey(key) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
export type FuzzySearchMatchValue = string | number;
|
||||
|
||||
export type FuzzySearchConfigValue =
|
||||
| FuzzySearchMatchValue
|
||||
| FuzzySearchMatchValue[]
|
||||
| Record<string, unknown>;
|
||||
|
||||
export interface FuzzySearchConfig {
|
||||
[type: string]: { [key: string]: FuzzySearchConfigValue[] };
|
||||
}
|
||||
|
||||
export interface FuzzySearchResult {
|
||||
[key: string]: FuzzySearchMatchValue[];
|
||||
}
|
||||
|
||||
export type FuzzySearchResultWriter =
|
||||
(key: string) => (value: FuzzySearchMatchValue) => void;
|
||||
@ -1,28 +1,33 @@
|
||||
import * as fs from 'fs';
|
||||
import { fuzzySearchPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
|
||||
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger';
|
||||
import {
|
||||
createDefaultFuzzySearchRuleRegistry,
|
||||
createFuzzySearchKeyword,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry';
|
||||
import { searchDictionaryRepository } from '@/qqbot/plugins/bangDream/tsugu/search/search-dictionary-repository';
|
||||
import type {
|
||||
FuzzySearchConfig,
|
||||
FuzzySearchMatchValue,
|
||||
FuzzySearchResult,
|
||||
FuzzySearchResultWriter,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
|
||||
type FuzzySearchMatchValue = string | number;
|
||||
type FuzzySearchConfigValue =
|
||||
| FuzzySearchMatchValue
|
||||
| FuzzySearchMatchValue[]
|
||||
| Record<string, unknown>;
|
||||
|
||||
interface FuzzySearchConfig {
|
||||
[type: string]: { [key: string]: FuzzySearchConfigValue[] };
|
||||
}
|
||||
|
||||
export interface FuzzySearchResult {
|
||||
[key: string]: FuzzySearchMatchValue[];
|
||||
}
|
||||
export type {
|
||||
FuzzySearchConfig,
|
||||
FuzzySearchConfigValue,
|
||||
FuzzySearchMatchValue,
|
||||
FuzzySearchResult,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
export { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher';
|
||||
|
||||
const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“”『』「」]|\S+/g;
|
||||
const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g;
|
||||
const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']);
|
||||
const RELATION_PATTERNS = [/^<\d+$/, /^>\d+$/, /^\d+-\d+$/];
|
||||
|
||||
export const config: FuzzySearchConfig =
|
||||
searchDictionaryRepository.loadConfig();
|
||||
const ruleRegistry = createDefaultFuzzySearchRuleRegistry(config);
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断对象是否包含指定自有属性。
|
||||
* 判断对象是否包含指定自有属性。
|
||||
*
|
||||
* @param source - 输入来源对象或数据集合。
|
||||
* @param key - 当前字段键名。
|
||||
@ -31,42 +36,9 @@ const hasOwn = (source: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(source, key);
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中加载模糊搜索配置文件。
|
||||
*
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
function loadConfig(): FuzzySearchConfig {
|
||||
const fileContent = fs.readFileSync(fuzzySearchPath, 'utf-8');
|
||||
logger('fuzzySearch', 'loaded fuzzy search config');
|
||||
return JSON.parse(fileContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中从等级关键词中提取数字等级。
|
||||
*
|
||||
* @param str - str参数。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function extractLvNumber(str: string): number | null {
|
||||
const match = str.match(/^lv(\d+)$/i);
|
||||
return match?.[1] ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断字符串是否为非负整数。
|
||||
*
|
||||
* @param value - 当前处理的值。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function isInteger(value: string): boolean {
|
||||
return /^(0|[1-9]\d*)$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中按空白和引号拆分搜索关键词。
|
||||
* 按空白和引号拆分搜索关键词。
|
||||
*
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
function extractKeywords(keyword: string): string[] {
|
||||
return (keyword.match(KEYWORD_PATTERN) || []).map((item) =>
|
||||
@ -75,105 +47,20 @@ function extractKeywords(keyword: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中统一关系表达式中的符号写法。
|
||||
*
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
function normalizeRelationKeyword(keyword: string): string {
|
||||
return keyword
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中创建向匹配结果追加值的写入器。
|
||||
* 创建向匹配结果追加值的写入器。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
*/
|
||||
const appendTo =
|
||||
(matches: FuzzySearchResult) =>
|
||||
const appendTo = (matches: FuzzySearchResult): FuzzySearchResultWriter =>
|
||||
(key: string) =>
|
||||
(value: FuzzySearchMatchValue): void => {
|
||||
(matches[key] ??= []).push(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中把配置键转换成数字或字符串匹配值。
|
||||
*
|
||||
* @param key - 当前字段键名。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
function parseConfigKey(key: string): FuzzySearchMatchValue {
|
||||
return isInteger(key) ? parseInt(key, 10) : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断配置值是否命中关键词。
|
||||
* 校验模糊搜索结果结构。
|
||||
*
|
||||
* @param value - 当前处理的值。
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function configValueMatches(
|
||||
value: FuzzySearchConfigValue,
|
||||
keyword: string,
|
||||
): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value === keyword;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(keyword);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return hasOwn(value, keyword);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中把配置命中结果写入模糊搜索结果。
|
||||
*
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
* @param push - push参数。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function appendConfigMatches(
|
||||
keyword: string,
|
||||
push: ReturnType<typeof appendTo>,
|
||||
): boolean {
|
||||
let matched = false;
|
||||
|
||||
for (const type in config) {
|
||||
const typeConfig = config[type];
|
||||
const pushType = push(type);
|
||||
|
||||
for (const key in typeConfig) {
|
||||
const matchValue = parseConfigKey(key);
|
||||
for (const value of typeConfig[key]) {
|
||||
if (!configValueMatches(value, keyword)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pushType(matchValue);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
export const config: FuzzySearchConfig = loadConfig();
|
||||
|
||||
// 自定义验证函数
|
||||
/**
|
||||
* 在模糊搜索入口中校验模糊搜索结果结构。
|
||||
*
|
||||
* @param value - 当前处理的值。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
export function isFuzzySearchResult(
|
||||
value: unknown,
|
||||
@ -189,71 +76,35 @@ export function isFuzzySearchResult(
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中把用户关键词解析成结构化匹配条件。
|
||||
* 把用户关键词解析成结构化匹配条件。
|
||||
*
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
export function fuzzySearch(keyword: string): FuzzySearchResult {
|
||||
const matches: FuzzySearchResult = {};
|
||||
const push = appendTo(matches);
|
||||
|
||||
for (const rawKeyword of extractKeywords(keyword)) {
|
||||
const keywordLowerCase = rawKeyword.toLowerCase();
|
||||
|
||||
if (isInteger(keywordLowerCase)) {
|
||||
push('_number')(parseInt(keywordLowerCase, 10));
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKeyword = normalizeRelationKeyword(keywordLowerCase);
|
||||
const lvNumber = extractLvNumber(normalizedKeyword);
|
||||
if (lvNumber !== null) {
|
||||
push('songLevels')(lvNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isValidRelationStr(normalizedKeyword)) {
|
||||
push('_relationStr')(normalizedKeyword);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (appendConfigMatches(normalizedKeyword, push)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
push('_all')(rawKeyword);
|
||||
ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断关系表达式是否可用于范围匹配。
|
||||
*
|
||||
* @param _relationStr - 关系表达式Str参数。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function isValidRelationStr(_relationStr: string): boolean {
|
||||
return RELATION_PATTERNS.some((pattern) => pattern.test(_relationStr));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断字段是否为模糊搜索保留键。
|
||||
* 判断字段是否为模糊搜索保留键。
|
||||
*
|
||||
* @param key - 当前字段键名。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function isReservedMatchKey(key: string): boolean {
|
||||
return RESERVED_MATCH_KEYS.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断候选值是否命中目标字段值。
|
||||
* 判断候选值是否命中目标字段值。
|
||||
*
|
||||
* @param candidates - 候选匹配值列表。
|
||||
* @param targetValue - 待比较的目标值。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function candidateMatches(
|
||||
candidates: FuzzySearchMatchValue[],
|
||||
@ -278,13 +129,12 @@ function candidateMatches(
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断数字别名是否命中目标字段。
|
||||
* 判断数字别名是否命中目标字段。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
* @param target - 待匹配或待写入的目标对象。
|
||||
* @param key - 当前字段键名。
|
||||
* @param numberTypeKey - 允许使用数字别名匹配的字段列表。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function numberAliasMatches(
|
||||
matches: FuzzySearchResult,
|
||||
@ -300,13 +150,12 @@ function numberAliasMatches(
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断目标对象指定字段是否命中搜索条件。
|
||||
* 判断目标对象指定字段是否命中搜索条件。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
* @param target - 待匹配或待写入的目标对象。
|
||||
* @param key - 当前字段键名。
|
||||
* @param numberTypeKey - 允许使用数字别名匹配的字段列表。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function targetMatchesKey(
|
||||
matches: FuzzySearchResult,
|
||||
@ -321,11 +170,10 @@ function targetMatchesKey(
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中判断兜底关键词是否命中任意目标值。
|
||||
* 判断兜底关键词是否命中任意目标值。
|
||||
*
|
||||
* @param targetValue - 待比较的目标值。
|
||||
* @param searchValue - 标准化后的搜索值。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function allKeywordMatches(targetValue: unknown, searchValue: string): boolean {
|
||||
if (typeof targetValue === 'string') {
|
||||
@ -341,11 +189,10 @@ function allKeywordMatches(targetValue: unknown, searchValue: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中处理targetIncludes全部关键词。
|
||||
* 判断目标对象是否包含兜底关键词。
|
||||
*
|
||||
* @param target - 待匹配或待写入的目标对象。
|
||||
* @param rawSearchValue - raw搜索值参数。
|
||||
* @returns 判断结果。
|
||||
* @param rawSearchValue - 原始搜索值。
|
||||
*/
|
||||
function targetIncludesAllKeyword(
|
||||
target: any,
|
||||
@ -365,10 +212,9 @@ function targetIncludesAllKeyword(
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中获取匹配KeyCount。
|
||||
* 获取匹配条件 key 数量。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function getMatchKeyCount(matches: FuzzySearchResult): number {
|
||||
let count = 0;
|
||||
@ -381,23 +227,21 @@ function getMatchKeyCount(matches: FuzzySearchResult): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中处理匹配结果Only全部。
|
||||
* 判断匹配结果是否只包含兜底关键词。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
* @param keyCount - keyCount参数。
|
||||
* @returns 判断结果。
|
||||
* @param keyCount - 匹配条件 key 数量。
|
||||
*/
|
||||
function matchesOnlyAll(matches: FuzzySearchResult, keyCount: number): boolean {
|
||||
return matches._all !== undefined && keyCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中执行结构化模糊搜索条件匹配。
|
||||
* 执行结构化模糊搜索条件匹配。
|
||||
*
|
||||
* @param matches - 模糊搜索命中结果。
|
||||
* @param target - 待匹配或待写入的目标对象。
|
||||
* @param numberTypeKey - 允许使用数字别名匹配的字段列表。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
export function match(
|
||||
matches: FuzzySearchResult,
|
||||
@ -435,82 +279,3 @@ export function match(
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
interface RelationChecker {
|
||||
pattern: RegExp;
|
||||
test: (num: number, match: RegExpMatchArray) => boolean;
|
||||
}
|
||||
|
||||
const RELATION_CHECKERS: RelationChecker[] = [
|
||||
{
|
||||
pattern: /^<(\d+)$/,
|
||||
/**
|
||||
* 在模糊搜索入口中处理test。
|
||||
*
|
||||
* @param num - num参数。
|
||||
* @param match - 匹配参数。
|
||||
*/
|
||||
test: (num, match) => num < parseFloat(match[1]),
|
||||
},
|
||||
{
|
||||
pattern: /^>(\d+)$/,
|
||||
/**
|
||||
* 在模糊搜索入口中处理test。
|
||||
*
|
||||
* @param num - num参数。
|
||||
* @param match - 匹配参数。
|
||||
*/
|
||||
test: (num, match) => num > parseFloat(match[1]),
|
||||
},
|
||||
{
|
||||
pattern: /^(\d+)-(\d+)$/,
|
||||
/**
|
||||
* 在模糊搜索入口中处理test。
|
||||
*
|
||||
* @param num - num参数。
|
||||
* @param match - 匹配参数。
|
||||
*/
|
||||
test: (num, match) =>
|
||||
num >= parseFloat(match[1]) && num <= parseFloat(match[2]),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 在模糊搜索入口中创建数值关系表达式匹配器。
|
||||
*
|
||||
* @param _relationStr - 关系表达式Str参数。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
function createRelationMatcher(_relationStr: string): (num: number) => boolean {
|
||||
for (const checker of RELATION_CHECKERS) {
|
||||
const relationMatch = _relationStr.match(checker.pattern);
|
||||
if (relationMatch) {
|
||||
return (num) => checker.test(num, relationMatch);
|
||||
}
|
||||
}
|
||||
throw new Error('Invalid relation string format');
|
||||
}
|
||||
|
||||
// 以下为数字与范围函数
|
||||
/**
|
||||
* 在模糊搜索入口中检查数值列表是否满足关系表达式。
|
||||
*
|
||||
* @param num - num参数。
|
||||
* @param _relationStrList - 关系表达式Str列表参数。
|
||||
* @returns 判断结果。
|
||||
*/
|
||||
export function checkRelationList(
|
||||
num: number,
|
||||
_relationStrList: string[],
|
||||
): boolean {
|
||||
for (const relationStr of _relationStrList) {
|
||||
try {
|
||||
if (createRelationMatcher(relationStr)(num)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
logger('fuzzySearch', 'Invalid relation string format');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
101
src/qqbot/plugins/bangDream/tsugu/search/relation-matcher.ts
Normal file
101
src/qqbot/plugins/bangDream/tsugu/search/relation-matcher.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger';
|
||||
|
||||
const RELATION_PATTERNS = [/^<\d+$/, /^>\d+$/, /^\d+-\d+$/];
|
||||
|
||||
interface RelationChecker {
|
||||
pattern: RegExp;
|
||||
test: (num: number, match: RegExpMatchArray) => boolean;
|
||||
}
|
||||
|
||||
const RELATION_CHECKERS: RelationChecker[] = [
|
||||
{
|
||||
pattern: /^<(\d+)$/,
|
||||
/**
|
||||
* 判断数字是否小于关系表达式右值。
|
||||
*
|
||||
* @param num - 待匹配数字。
|
||||
* @param match - 关系表达式捕获结果。
|
||||
*/
|
||||
test: (num, match) => num < parseFloat(match[1]),
|
||||
},
|
||||
{
|
||||
pattern: /^>(\d+)$/,
|
||||
/**
|
||||
* 判断数字是否大于关系表达式右值。
|
||||
*
|
||||
* @param num - 待匹配数字。
|
||||
* @param match - 关系表达式捕获结果。
|
||||
*/
|
||||
test: (num, match) => num > parseFloat(match[1]),
|
||||
},
|
||||
{
|
||||
pattern: /^(\d+)-(\d+)$/,
|
||||
/**
|
||||
* 判断数字是否落入关系表达式闭区间。
|
||||
*
|
||||
* @param num - 待匹配数字。
|
||||
* @param match - 关系表达式捕获结果。
|
||||
*/
|
||||
test: (num, match) =>
|
||||
num >= parseFloat(match[1]) && num <= parseFloat(match[2]),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 统一关系表达式中的符号写法。
|
||||
*
|
||||
* @param keyword - 用户输入的搜索关键词。
|
||||
*/
|
||||
export function normalizeRelationKeyword(keyword: string): string {
|
||||
return keyword
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断关系表达式是否可用于范围匹配。
|
||||
*
|
||||
* @param relationStr - 关系表达式。
|
||||
*/
|
||||
export function isValidRelationStr(relationStr: string): boolean {
|
||||
return RELATION_PATTERNS.some((pattern) => pattern.test(relationStr));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数值关系表达式匹配器。
|
||||
*
|
||||
* @param relationStr - 关系表达式。
|
||||
*/
|
||||
function createRelationMatcher(relationStr: string): (num: number) => boolean {
|
||||
for (const checker of RELATION_CHECKERS) {
|
||||
const relationMatch = relationStr.match(checker.pattern);
|
||||
if (relationMatch) {
|
||||
return (num) => checker.test(num, relationMatch);
|
||||
}
|
||||
}
|
||||
throw new Error('Invalid relation string format');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数值列表是否满足关系表达式。
|
||||
*
|
||||
* @param num - 待匹配数字。
|
||||
* @param relationStrList - 关系表达式列表。
|
||||
*/
|
||||
export function checkRelationList(
|
||||
num: number,
|
||||
relationStrList: string[],
|
||||
): boolean {
|
||||
for (const relationStr of relationStrList) {
|
||||
try {
|
||||
if (createRelationMatcher(relationStr)(num)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
logger('fuzzySearch', 'Invalid relation string format');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import * as fs from 'fs';
|
||||
import { fuzzySearchPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
|
||||
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger';
|
||||
import type { FuzzySearchConfig } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
|
||||
export class SearchDictionaryRepository {
|
||||
constructor(private readonly filePath = fuzzySearchPath) {}
|
||||
|
||||
/**
|
||||
* 读取搜索别名配置。
|
||||
*/
|
||||
loadConfig(): FuzzySearchConfig {
|
||||
const fileContent = fs.readFileSync(this.filePath, 'utf-8');
|
||||
logger('fuzzySearch', 'loaded fuzzy search config');
|
||||
return JSON.parse(fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
export const searchDictionaryRepository = new SearchDictionaryRepository();
|
||||
@ -3,6 +3,11 @@ import {
|
||||
fuzzySearch,
|
||||
match,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search';
|
||||
import {
|
||||
createDefaultFuzzySearchRuleRegistry,
|
||||
createFuzzySearchKeyword,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-rule-registry';
|
||||
import type { FuzzySearchResult } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
|
||||
import { createTsuguEntityMatcher } from '@/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher';
|
||||
|
||||
describe('Tsugu fuzzy search helpers', () => {
|
||||
@ -36,6 +41,23 @@ describe('Tsugu fuzzy search helpers', () => {
|
||||
expect(checkRelationList(136, ['<100', '130-140'])).toBe(true);
|
||||
expect(checkRelationList(136, ['<100', '>200'])).toBe(false);
|
||||
});
|
||||
|
||||
it('routes configured aliases through the rule registry', () => {
|
||||
const registry = createDefaultFuzzySearchRuleRegistry({
|
||||
songs: {
|
||||
'136': ['夏祭り'],
|
||||
},
|
||||
});
|
||||
const result: FuzzySearchResult = {};
|
||||
const push = (key: string) => (value: string | number) => {
|
||||
(result[key] ??= []).push(value);
|
||||
};
|
||||
|
||||
expect(registry.match(createFuzzySearchKeyword('夏祭り'), push)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result).toEqual({ songs: [136] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tsugu entity list matcher', () => {
|
||||
@ -79,4 +101,33 @@ describe('Tsugu entity list matcher', () => {
|
||||
|
||||
expect(result.map((entity) => entity.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('reads entity source lazily when matching', () => {
|
||||
let dynamicSource: Record<string, unknown> = {
|
||||
'1': {},
|
||||
};
|
||||
const matchDynamicEntity = createTsuguEntityMatcher<TestEntity>({
|
||||
source: () => dynamicSource,
|
||||
createEntity,
|
||||
isReleased: (entity, displayedServerList) =>
|
||||
displayedServerList.some((server) => entity.releasedAt[server] != null),
|
||||
isMatched: (matches, entity) => match(matches, entity, []),
|
||||
relationValue: (entity) => entity.id,
|
||||
});
|
||||
|
||||
expect(matchDynamicEntity({ _relationStr: ['1-3'] }, [CN_SERVER])).toHaveLength(
|
||||
1,
|
||||
);
|
||||
|
||||
dynamicSource = {
|
||||
'1': {},
|
||||
'3': {},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchDynamicEntity({ _relationStr: ['1-3'] }, [CN_SERVER]).map(
|
||||
(entity) => entity.id,
|
||||
),
|
||||
).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user