refactor: 收口 BangDream 搜索和活动底图

This commit is contained in:
sunlei 2026-06-06 20:27:51 +08:00
parent 4b3a2bf3b6
commit 9f0293a977
18 changed files with 681 additions and 296 deletions

View File

@ -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 创建模型或读取主数据。 - `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/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` - 已新增 `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。 - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。
- 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。
@ -312,6 +314,13 @@ export interface TsuguHook {
- 新增昵称或难度别名无需改 matcher 代码。 - 新增昵称或难度别名无需改 matcher 代码。
- 旧 search/fuzzy-search 测试全部通过。 - 旧 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 ### Phase 5渲染 theme、layout spec 和 section builder
目标Canvas 绘制仍保留函数式性能,但颜色、尺寸、字体和区块顺序收口。 目标Canvas 绘制仍保留函数式性能,但颜色、尺寸、字体和区块顺序收口。

View 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

View File

@ -7,7 +7,7 @@ import { getPresentEvent } from '../tsugu/models/event';
import { Gacha, getPresentGachaList } from '../tsugu/models/gacha'; import { Gacha, getPresentGachaList } from '../tsugu/models/gacha';
import { Server } from '../tsugu/models/server'; import { Server } from '../tsugu/models/server';
import { Song } from '../tsugu/models/song'; 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 { fuzzySearch, type FuzzySearchResult } from '../tsugu/search/fuzzy-search';
import { drawCardDetail } from '../tsugu/command-renderers/card-detail'; import { drawCardDetail } from '../tsugu/command-renderers/card-detail';
import { drawCardList } from '../tsugu/command-renderers/card-list'; 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 }; const data = mainAPI as { cards?: unknown; songs?: unknown };
if (!data.songs || !data.cards) { if (!data.songs || !data.cards) {
throw new Error('Tsugu 数据配置未加载'); throw new Error('Tsugu 数据配置未加载');
@ -79,6 +80,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
operationKey: QqbotBangDreamOperationKey, operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput, input: QqbotBangDreamCommandInput,
) { ) {
await waitForMainDataReady();
const operation = getBangDreamOperationDefinition(operationKey); const operation = getBangDreamOperationDefinition(operationKey);
if (!operation) { if (!operation) {
throw new Error(`BangDream 插件能力不存在:${operationKey}`); throw new Error(`BangDream 插件能力不存在:${operationKey}`);

View File

@ -63,7 +63,7 @@ export async function drawCardList(
//计算模糊搜索结果 //计算模糊搜索结果
export const matchCardList = createTsuguEntityMatcher<Card>({ export const matchCardList = createTsuguEntityMatcher<Card>({
source: cardRepository.getSource(), source: () => cardRepository.getSource(),
/** /**
* QQBot Entity * QQBot Entity
* *

View File

@ -144,7 +144,7 @@ export async function drawEventList(
} }
const matchEventList = createTsuguEntityMatcher<Event>({ const matchEventList = createTsuguEntityMatcher<Event>({
source: eventRepository.getSource(), source: () => eventRepository.getSource(),
/** /**
* QQBot Entity * QQBot Entity
* *

View File

@ -118,7 +118,7 @@ export async function drawSongList(
// 计算歌曲模糊搜索结果 // 计算歌曲模糊搜索结果
export const matchSongList = createTsuguEntityMatcher<Song>({ export const matchSongList = createTsuguEntityMatcher<Song>({
source: songRepository.getSource(), source: () => songRepository.getSource(),
/** /**
* QQBot Entity * QQBot Entity
* *

View File

@ -1,8 +1,20 @@
import axios from 'axios'; import axios from 'axios';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamPositiveInteger,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options';
const errUrl: string[] = []; 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 } : {}; const headers = eTag ? { 'If-None-Match': eTag } : {};
let response; let response;
try { try {
response = await axios.get(url, { headers, responseType: 'arraybuffer' }); response = await axios.get(url, {
headers,
responseType: 'arraybuffer',
timeout: getRequestTimeoutMs(),
});
} catch (error) { } catch (error) {
if (error.response && error.response.status === 304) { if (error.response && error.response.status === 304) {
const cachedData = fs.readFileSync(cacheFilePath); const cachedData = fs.readFileSync(cacheFilePath);
@ -130,7 +146,11 @@ export async function getJsonAndSave(
const headers = eTag ? { 'If-None-Match': eTag } : {}; const headers = eTag ? { 'If-None-Match': eTag } : {};
let response; let response;
try { try {
response = await axios.get(url, { headers, responseType: 'arraybuffer' }); response = await axios.get(url, {
headers,
responseType: 'arraybuffer',
timeout: getRequestTimeoutMs(),
});
} catch (error) { } catch (error) {
if (error.response && error.response.status === 304) { if (error.response && error.response.status === 304) {
const cachedData = fs.readFileSync(cacheFilePath, 'utf-8'); const cachedData = fs.readFileSync(cacheFilePath, 'utf-8');

View File

@ -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 { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
import { import {
@ -80,12 +80,18 @@ class EventDataRepository {
* *
* @param event - * @param event -
*/ */
async getBackgroundImage(event: EventAssetContext): Promise<Image> { async getBackgroundImage(event: EventAssetContext): Promise<Image | Canvas> {
const server = getServerByPriority(event.startAt); const server = getServerByPriority(event.startAt);
const bgImageBuffer = await bangDreamBestdoriProvider.getAsset( const bgImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/bg_eventtop.png`, `/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); 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 * Logo
* *

View File

@ -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 { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute';
@ -213,7 +213,7 @@ export class Event {
* *
* @returns * @returns
*/ */
async getEventBGImage(): Promise<Image> { async getEventBGImage(): Promise<Image | Canvas> {
return await eventDataRepository.getBackgroundImage(this); return await eventDataRepository.getBackgroundImage(this);
} }
//活动规则轮播图 //活动规则轮播图

View File

@ -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 { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
import { bangDreamStaticPatchProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/static-patch-provider'; import { bangDreamStaticPatchProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/static-patch-provider';
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; 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 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 //加载mainAPI
/** /**
@ -71,11 +96,24 @@ async function loadMainAPI(useCache: boolean = false) {
} }
logger('mainAPI', 'initializing...'); logger('mainAPI', 'initializing...');
loadMainAPI(true).then(() => { const initialLoadPromise = loadMainAPI(true).then(() => {
logger('mainAPI', 'initializing done'); logger('mainAPI', 'initializing done');
loadMainAPI(); loadMainAPI();
}); });
setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次 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; export default mainAPI;

View File

@ -11,6 +11,7 @@ export const BANGDREAM_TSUGU_ENV_KEYS = {
compress: 'BANGDREAM_TSUGU_COMPRESS', compress: 'BANGDREAM_TSUGU_COMPRESS',
displayedServers: 'BANGDREAM_TSUGU_DISPLAYED_SERVERS', displayedServers: 'BANGDREAM_TSUGU_DISPLAYED_SERVERS',
hhwxBaseUrl: 'BANGDREAM_TSUGU_HHWX_BASE_URL', hhwxBaseUrl: 'BANGDREAM_TSUGU_HHWX_BASE_URL',
mainDataReadyTimeoutMs: 'BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS',
mainServer: 'BANGDREAM_TSUGU_MAIN_SERVER', mainServer: 'BANGDREAM_TSUGU_MAIN_SERVER',
requestTimeoutMs: 'BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS', requestTimeoutMs: 'BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS',
retryCount: 'BANGDREAM_TSUGU_RETRY_COUNT', 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_CN_BLOCKED_EVENT_IDS: readonly number[] = [];
export const BANGDREAM_DEFAULT_NO_BANG_DAYS = 1; 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( export function normalizeBangDreamBoolean(
value: unknown, value: unknown,
fallback: boolean, fallback: boolean,

View File

@ -1,11 +1,9 @@
import type { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import type { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { import { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher';
checkRelationList, import type { FuzzySearchResult } from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
FuzzySearchResult,
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search';
interface TsuguEntityMatcherOptions<T> { interface TsuguEntityMatcherOptions<T> {
source: Record<string, unknown>; source: Record<string, unknown> | (() => Record<string, unknown>);
createEntity: (id: number) => T; createEntity: (id: number) => T;
isCandidate?: (entity: T) => boolean; isCandidate?: (entity: T) => boolean;
isReleased: (entity: T, displayedServerList: Server[]) => boolean; isReleased: (entity: T, displayedServerList: Server[]) => boolean;
@ -49,6 +47,15 @@ const getRelationList = (matches: FuzzySearchResult): string[] | undefined =>
? (matches._relationStr as string[]) ? (matches._relationStr as string[])
: undefined; : undefined;
/**
* QQBot
*
* @param source -
*/
const getCurrentSource = (
source: TsuguEntityMatcherOptions<unknown>['source'],
) => (typeof source === 'function' ? source() : source);
/** /**
* QQBot Check关系表达式 * QQBot Check关系表达式
* *
@ -76,13 +83,14 @@ export const createTsuguEntityMatcher =
}: TsuguEntityMatcherOptions<T>) => }: TsuguEntityMatcherOptions<T>) =>
(matches: FuzzySearchResult, displayedServerList: Server[]): T[] => { (matches: FuzzySearchResult, displayedServerList: Server[]): T[] => {
const result: T[] = []; const result: T[] = [];
const currentSource = getCurrentSource(source);
const relationList = getRelationList(matches); const relationList = getRelationList(matches);
const relationOnly = const relationOnly =
relationList !== undefined && getMatchKeyCount(matches) === 1; relationList !== undefined && getMatchKeyCount(matches) === 1;
const useRelation = shouldCheckRelation(relationList, relationOnly); const useRelation = shouldCheckRelation(relationList, relationOnly);
for (const id in source) { for (const id in currentSource) {
if (!hasOwn(source, id)) { if (!hasOwn(currentSource, id)) {
continue; continue;
} }

View File

@ -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;
}

View File

@ -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;

View File

@ -1,28 +1,33 @@
import * as fs from 'fs'; import {
import { fuzzySearchPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; createDefaultFuzzySearchRuleRegistry,
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; 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; export type {
type FuzzySearchConfigValue = FuzzySearchConfig,
| FuzzySearchMatchValue FuzzySearchConfigValue,
| FuzzySearchMatchValue[] FuzzySearchMatchValue,
| Record<string, unknown>; FuzzySearchResult,
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search-types';
interface FuzzySearchConfig { export { checkRelationList } from '@/qqbot/plugins/bangDream/tsugu/search/relation-matcher';
[type: string]: { [key: string]: FuzzySearchConfigValue[] };
}
export interface FuzzySearchResult {
[key: string]: FuzzySearchMatchValue[];
}
const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“”『』「」]|\S+/g; const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“”『』「」]|\S+/g;
const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g; const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g;
const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); 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 source -
* @param key - * @param key -
@ -31,42 +36,9 @@ const hasOwn = (source: object, key: string) =>
Object.prototype.hasOwnProperty.call(source, key); 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 - * @param keyword -
* @returns
*/ */
function extractKeywords(keyword: string): string[] { function extractKeywords(keyword: string): string[] {
return (keyword.match(KEYWORD_PATTERN) || []).map((item) => 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(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(//g, '>')
.replace(//g, '<');
}
/**
*
* *
* @param matches - * @param matches -
*/ */
const appendTo = const appendTo = (matches: FuzzySearchResult): FuzzySearchResultWriter =>
(matches: FuzzySearchResult) =>
(key: string) => (key: string) =>
(value: FuzzySearchMatchValue): void => { (value: FuzzySearchMatchValue): void => {
(matches[key] ??= []).push(value); (matches[key] ??= []).push(value);
}; };
/** /**
* *
*
* @param key -
* @returns
*/
function parseConfigKey(key: string): FuzzySearchMatchValue {
return isInteger(key) ? parseInt(key, 10) : key;
}
/**
*
* *
* @param value - * @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( export function isFuzzySearchResult(
value: unknown, value: unknown,
@ -189,71 +76,35 @@ export function isFuzzySearchResult(
} }
/** /**
* *
* *
* @param keyword - * @param keyword -
* @returns
*/ */
export function fuzzySearch(keyword: string): FuzzySearchResult { export function fuzzySearch(keyword: string): FuzzySearchResult {
const matches: FuzzySearchResult = {}; const matches: FuzzySearchResult = {};
const push = appendTo(matches); const push = appendTo(matches);
for (const rawKeyword of extractKeywords(keyword)) { for (const rawKeyword of extractKeywords(keyword)) {
const keywordLowerCase = rawKeyword.toLowerCase(); ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push);
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);
} }
return matches; return matches;
} }
/** /**
* *
*
* @param _relationStr - Str参数
* @returns
*/
function isValidRelationStr(_relationStr: string): boolean {
return RELATION_PATTERNS.some((pattern) => pattern.test(_relationStr));
}
/**
*
* *
* @param key - * @param key -
* @returns
*/ */
function isReservedMatchKey(key: string): boolean { function isReservedMatchKey(key: string): boolean {
return RESERVED_MATCH_KEYS.has(key); return RESERVED_MATCH_KEYS.has(key);
} }
/** /**
* *
* *
* @param candidates - * @param candidates -
* @param targetValue - * @param targetValue -
* @returns
*/ */
function candidateMatches( function candidateMatches(
candidates: FuzzySearchMatchValue[], candidates: FuzzySearchMatchValue[],
@ -278,13 +129,12 @@ function candidateMatches(
} }
/** /**
* *
* *
* @param matches - * @param matches -
* @param target - * @param target -
* @param key - * @param key -
* @param numberTypeKey - 使 * @param numberTypeKey - 使
* @returns
*/ */
function numberAliasMatches( function numberAliasMatches(
matches: FuzzySearchResult, matches: FuzzySearchResult,
@ -300,13 +150,12 @@ function numberAliasMatches(
} }
/** /**
* *
* *
* @param matches - * @param matches -
* @param target - * @param target -
* @param key - * @param key -
* @param numberTypeKey - 使 * @param numberTypeKey - 使
* @returns
*/ */
function targetMatchesKey( function targetMatchesKey(
matches: FuzzySearchResult, matches: FuzzySearchResult,
@ -321,11 +170,10 @@ function targetMatchesKey(
} }
/** /**
* *
* *
* @param targetValue - * @param targetValue -
* @param searchValue - * @param searchValue -
* @returns
*/ */
function allKeywordMatches(targetValue: unknown, searchValue: string): boolean { function allKeywordMatches(targetValue: unknown, searchValue: string): boolean {
if (typeof targetValue === 'string') { if (typeof targetValue === 'string') {
@ -341,11 +189,10 @@ function allKeywordMatches(targetValue: unknown, searchValue: string): boolean {
} }
/** /**
* targetIncludes全部关键 *
* *
* @param target - * @param target -
* @param rawSearchValue - raw搜索值参数 * @param rawSearchValue -
* @returns
*/ */
function targetIncludesAllKeyword( function targetIncludesAllKeyword(
target: any, target: any,
@ -365,10 +212,9 @@ function targetIncludesAllKeyword(
} }
/** /**
* KeyCount * key
* *
* @param matches - * @param matches -
* @returns
*/ */
function getMatchKeyCount(matches: FuzzySearchResult): number { function getMatchKeyCount(matches: FuzzySearchResult): number {
let count = 0; let count = 0;
@ -381,23 +227,21 @@ function getMatchKeyCount(matches: FuzzySearchResult): number {
} }
/** /**
* Only全部 *
* *
* @param matches - * @param matches -
* @param keyCount - keyCount参数 * @param keyCount - key
* @returns
*/ */
function matchesOnlyAll(matches: FuzzySearchResult, keyCount: number): boolean { function matchesOnlyAll(matches: FuzzySearchResult, keyCount: number): boolean {
return matches._all !== undefined && keyCount === 1; return matches._all !== undefined && keyCount === 1;
} }
/** /**
* *
* *
* @param matches - * @param matches -
* @param target - * @param target -
* @param numberTypeKey - 使 * @param numberTypeKey - 使
* @returns
*/ */
export function match( export function match(
matches: FuzzySearchResult, matches: FuzzySearchResult,
@ -435,82 +279,3 @@ export function match(
return matched; 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;
}

View 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(/&gt;/g, '>')
.replace(/&lt;/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;
}

View File

@ -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();

View File

@ -3,6 +3,11 @@ import {
fuzzySearch, fuzzySearch,
match, match,
} from '@/qqbot/plugins/bangDream/tsugu/search/fuzzy-search'; } 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'; import { createTsuguEntityMatcher } from '@/qqbot/plugins/bangDream/tsugu/search/entity-list-matcher';
describe('Tsugu fuzzy search helpers', () => { 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', '130-140'])).toBe(true);
expect(checkRelationList(136, ['<100', '>200'])).toBe(false); 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', () => { describe('Tsugu entity list matcher', () => {
@ -79,4 +101,33 @@ describe('Tsugu entity list matcher', () => {
expect(result.map((entity) => entity.id)).toEqual([3]); 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]);
});
}); });