feat: 添加Bilibili视频信息客户端

This commit is contained in:
sunlei 2026-06-19 18:45:47 +08:00
parent 87522a4326
commit 2e98c430d4
6 changed files with 638 additions and 0 deletions

View File

@ -0,0 +1,69 @@
import type {
BilibiliCardPluginHost,
BilibiliCardRuntimeConfig,
} from '../domain/bilibili-card.types';
const CONFIG_RULES = {
dedupeTtlMs: {
defaultValue: 600000,
key: 'QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS',
max: 3600000,
min: 0,
},
descMaxLength: {
defaultValue: 80,
key: 'QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH',
max: 300,
min: 0,
},
httpTimeoutMs: {
defaultValue: 6000,
key: 'QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS',
max: 15000,
min: 1000,
},
maxRedirects: {
defaultValue: 5,
key: 'QQBOT_BILIBILI_CARD_MAX_REDIRECTS',
max: 10,
min: 0,
},
} as const;
/**
* Reads Bilibili card runtime config from the package host and clamps unsafe values.
* @param host - Package-local host facade backed by the worker config snapshot.
* @returns Runtime config used by redirect resolution, API requests, dedupe and reply text.
*/
export function readBilibiliCardRuntimeConfig(
host: BilibiliCardPluginHost,
): BilibiliCardRuntimeConfig {
return {
dedupeTtlMs: readClampedInteger(host, CONFIG_RULES.dedupeTtlMs),
descMaxLength: readClampedInteger(host, CONFIG_RULES.descMaxLength),
httpTimeoutMs: readClampedInteger(host, CONFIG_RULES.httpTimeoutMs),
maxRedirects: readClampedInteger(host, CONFIG_RULES.maxRedirects),
};
}
/**
* Reads one numeric config value and constrains it to the allowed runtime range.
* @param host - Package-local host facade that exposes manifest config values.
* @param rule - Config key, fallback and inclusive bounds for one Bilibili setting.
* @returns Integer value clamped between the rule's minimum and maximum.
*/
function readClampedInteger(
host: BilibiliCardPluginHost,
rule: {
defaultValue: number;
key: string;
max: number;
min: number;
},
) {
const value = Number(host.getConfig(rule.key));
const normalized = Number.isFinite(value)
? Math.trunc(value)
: rule.defaultValue;
return Math.min(rule.max, Math.max(rule.min, normalized));
}

View File

@ -17,3 +17,68 @@ export type BilibiliUrlExtractionInput = {
rawEvent?: Record<string, unknown>; rawEvent?: Record<string, unknown>;
rawMessage?: string; rawMessage?: string;
}; };
export type BilibiliCardRuntimeConfig = {
dedupeTtlMs: number;
descMaxLength: number;
httpTimeoutMs: number;
maxRedirects: number;
};
export type BilibiliCardHostJsonRequest = {
context: string;
failureMessage: (statusCode: number) => string;
invalidJsonMessage: string;
method?: string;
timeoutMessage: string;
timeoutMs: number;
url: URL;
};
export type BilibiliCardRedirectRequest = {
context?: string;
maxRedirects: number;
timeoutMessage?: string;
timeoutMs: number;
url: string;
};
export type BilibiliCardRedirectResult = {
finalUrl: string;
redirects: string[];
};
export type BilibiliCardPluginHost = {
getBoundEventPluginKeys: (selfId: string) => Promise<string[]>;
getConfig: <T = string>(key: string) => T | undefined;
requestJson: <T = unknown>(
request: BilibiliCardHostJsonRequest,
) => Promise<T>;
resolveRedirect: (
request: BilibiliCardRedirectRequest,
) => Promise<BilibiliCardRedirectResult>;
sendText: (input: {
channelId?: string;
guildId?: string;
message: string;
selfId: string;
targetId: string;
targetType: string;
}) => Promise<unknown>;
warn?: (message: string) => void;
};
export type BilibiliVideoInfo = {
aid: number;
bvid: string;
desc: string;
duration: number;
ownerName: string;
pic: string;
stat: {
danmaku: number;
like: number;
view: number;
};
title: string;
};

View File

@ -0,0 +1,85 @@
import type {
BilibiliCardRuntimeConfig,
BilibiliVideoInfo,
} from './bilibili-card.types';
/**
* Formats a Bilibili video summary as plain text for QQBot replies.
* @param video - Normalized video info returned by the package-local Bilibili client.
* @param config - Runtime config that controls the maximum displayed description length.
* @returns Concise plain text reply with a canonical Bilibili video URL.
*/
export function formatBilibiliVideoReply(
video: BilibiliVideoInfo,
config: BilibiliCardRuntimeConfig,
) {
const lines = [
'Bilibili 视频解析',
`标题:${video.title || '未知标题'}`,
`UP${video.ownerName || '未知UP主'}`,
`时长:${formatBilibiliDuration(video.duration)}`,
`播放:${formatBilibiliStat(video.stat.view)} 弹幕:${formatBilibiliStat(
video.stat.danmaku,
)} ${formatBilibiliStat(video.stat.like)}`,
`链接:${buildCanonicalBilibiliVideoUrl(video)}`,
];
const desc = truncateBilibiliDescription(video.desc, config.descMaxLength);
if (desc) lines.push(`简介:${desc}`);
return lines.join('\n');
}
/**
* Builds the canonical public Bilibili video URL used in replies.
* @param video - Normalized video info containing either a BV id or an av id fallback.
* @returns Public Bilibili video URL that does not echo short links.
*/
function buildCanonicalBilibiliVideoUrl(video: BilibiliVideoInfo) {
const videoId = video.bvid || `av${video.aid}`;
return `https://www.bilibili.com/video/${videoId}`;
}
/**
* Formats Bilibili stat counters using compact Chinese units.
* @param value - Raw counter from the Bilibili video API response.
* @returns Counter text such as `7890` or `12.3万`.
*/
function formatBilibiliStat(value: number) {
const normalized = Math.max(0, Math.floor(value || 0));
if (normalized < 10000) return `${normalized}`;
const wan = normalized / 10000;
const formatted = wan >= 100 ? `${Math.round(wan)}` : wan.toFixed(1);
return `${formatted.replace(/\.0$/, '')}`;
}
/**
* Formats a duration in seconds as `mm:ss` or `hh:mm:ss`.
* @param seconds - Duration seconds from the Bilibili video API response.
* @returns Padded duration suitable for compact message replies.
*/
function formatBilibiliDuration(seconds: number) {
const normalized = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(normalized / 3600);
const minutes = Math.floor((normalized % 3600) / 60);
const restSeconds = normalized % 60;
if (hours > 0) {
return [hours, minutes, restSeconds]
.map((item) => `${item}`.padStart(2, '0'))
.join(':');
}
return [minutes, restSeconds]
.map((item) => `${item}`.padStart(2, '0'))
.join(':');
}
/**
* Converts multi-line descriptions into one compact line and applies the configured limit.
* @param desc - Raw Bilibili description text from the video API response.
* @param maxLength - Maximum number of visible characters configured for this plugin.
* @returns Trimmed description, with an ellipsis when content was shortened.
*/
function truncateBilibiliDescription(desc: string, maxLength: number) {
const normalized = desc.replace(/\s+/gu, ' ').trim();
if (!normalized || maxLength <= 0) return '';
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength)}`;
}

View File

@ -0,0 +1,108 @@
import type {
BilibiliCardHostJsonRequest,
BilibiliCardPluginHost,
} from '../../domain/bilibili-card.types';
export type BilibiliCardGenericHost = Record<string, unknown>;
/**
* Creates the package-local host adapter over the generic worker host facade.
* @param host - Generic worker host object supplied by the plugin platform runtime.
* @param configSnapshot - Startup config values captured by the worker runtime.
* @returns Package-local host contract used by Bilibili card domain and integration code.
*/
export function createBilibiliCardGenericHostAdapter(
host: BilibiliCardGenericHost,
configSnapshot: Record<string, string | undefined>,
): BilibiliCardPluginHost {
return {
/**
* Reads event plugin bindings through the generic host bridge.
* @param selfId - QQBot account id from a normalized message event.
* @returns Bound event plugin keys for the account.
*/
getBoundEventPluginKeys: async (selfId) =>
await callBilibiliCardGenericHost(host, 'getBoundEventPluginKeys', selfId),
/**
* Reads Bilibili card config from the worker startup snapshot.
* @param key - Runtime config key declared by the Bilibili card manifest.
* @returns Snapshot value cast to the requested config type.
*/
getConfig: <T = string>(key: string) =>
configSnapshot[key] as T | undefined,
/**
* Performs JSON HTTP requests through the generic host bridge.
* @param request - Package-local HTTP request options from `BilibiliVideoClient`.
* @returns Parsed JSON payload returned by the host HTTP client.
*/
requestJson: async <T = unknown>(request) =>
await callBilibiliCardGenericHost<T>(
host,
'requestJson',
serializeBilibiliCardJsonRequest(request),
),
/**
* Resolves short-link redirects through the generic host bridge.
* @param request - Redirect request containing URL, timeout and max redirect budget.
* @returns Final URL and redirect chain returned by the host HTTP client.
*/
resolveRedirect: async (request) =>
await callBilibiliCardGenericHost(host, 'resolveRedirect', request),
/**
* Sends a plain text QQBot reply through the generic host bridge.
* @param input - Target conversation and message text produced by the plugin.
* @returns Host send result.
*/
sendText: async (input) =>
await callBilibiliCardGenericHost(host, 'sendText', input),
/**
* Emits a package warning through the generic host when available.
* @param message - Warning message safe for platform logs.
*/
warn: (message) => {
const warn = host.warn;
if (typeof warn === 'function') warn(message);
},
};
}
/**
* Calls one generic host method and fails with a package-owned error when absent.
* @param host - Generic worker host facade supplied by the platform runtime.
* @param method - Host capability required by the Bilibili card adapter.
* @param args - Positional arguments accepted by the host facade method.
* @returns Host method result cast to the requested package-local type.
*/
export async function callBilibiliCardGenericHost<TResult = unknown>(
host: BilibiliCardGenericHost,
method: string,
...args: unknown[]
): Promise<TResult> {
const fn = host[method];
if (typeof fn !== 'function') {
throw new Error(`Bilibili Card generic host 缺少 ${method}`);
}
return (await fn(...args)) as TResult;
}
/**
* Converts package-local JSON request options to worker-safe generic host request data.
* @param request - HTTP request containing URL and function-based failure message.
* @returns Serializable request data accepted by the generic host bridge.
*/
export function serializeBilibiliCardJsonRequest(
request: BilibiliCardHostJsonRequest,
) {
const statusPlaceholder = 599;
return {
context: request.context,
failureMessageTemplate: request
.failureMessage(statusPlaceholder)
.replaceAll(`${statusPlaceholder}`, '{statusCode}'),
invalidJsonMessage: request.invalidJsonMessage,
method: request.method,
timeoutMessage: request.timeoutMessage,
timeoutMs: request.timeoutMs,
url: request.url.toString(),
};
}

View File

@ -0,0 +1,142 @@
import type {
BilibiliCardPluginHost,
BilibiliCardRuntimeConfig,
BilibiliVideoInfo,
BilibiliVideoReference,
} from '../../domain/bilibili-card.types';
type BilibiliViewResponse = {
code?: number;
data?: {
aid?: unknown;
bvid?: unknown;
desc?: unknown;
duration?: unknown;
owner?: {
name?: unknown;
};
pic?: unknown;
stat?: {
danmaku?: unknown;
like?: unknown;
view?: unknown;
};
title?: unknown;
};
message?: unknown;
};
export class BilibiliVideoClient {
/**
* Initializes the host-mediated Bilibili video client.
* @param host - Package-local host facade that owns all external HTTP access.
*/
constructor(private readonly host: BilibiliCardPluginHost) {}
/**
* Fetches and normalizes one Bilibili video through the official view endpoint.
* @param reference - Parsed BV or av reference produced by the URL parser or redirect resolver.
* @param config - Runtime config containing the host request timeout budget.
* @returns Normalized video info safe for reply formatting.
*/
async fetchVideo(
reference: BilibiliVideoReference,
config: Pick<BilibiliCardRuntimeConfig, 'httpTimeoutMs'>,
): Promise<BilibiliVideoInfo> {
const response = await this.host.requestJson<BilibiliViewResponse>({
context: 'Bilibili 视频信息获取',
/**
* Builds a readable HTTP failure message for host-mediated Bilibili API requests.
* @param statusCode - HTTP status code reported by the generic host HTTP client.
* @returns Chinese error text with the status code preserved.
*/
failureMessage: (statusCode) =>
`Bilibili 视频信息获取失败HTTP ${statusCode}`,
invalidJsonMessage: 'Bilibili 视频信息返回不是合法 JSON',
method: 'GET',
timeoutMessage: 'Bilibili 视频信息获取超时',
timeoutMs: config.httpTimeoutMs,
url: buildBilibiliViewUrl(reference),
});
if (response.code !== 0) {
throw new Error(
`Bilibili 视频信息获取失败:${normalizeBilibiliMessage(
response.message,
)}`,
);
}
return normalizeBilibiliVideoInfo(response.data, reference);
}
}
/**
* Builds the official Bilibili web-interface view endpoint URL for a video reference.
* @param reference - Parsed BV or av reference produced by URL parsing.
* @returns Official Bilibili API URL with either `bvid` or `aid` query parameter.
*/
function buildBilibiliViewUrl(reference: BilibiliVideoReference) {
const url = new URL('https://api.bilibili.com/x/web-interface/view');
url.searchParams.set(reference.kind === 'bvid' ? 'bvid' : 'aid', reference.value);
return url;
}
/**
* Normalizes the Bilibili API payload into the package-local video info contract.
* @param data - Raw `data` object returned by Bilibili's view endpoint.
* @param reference - Original parsed reference used as a fallback for missing identifiers.
* @returns Video info with safe fallback strings and numbers.
*/
function normalizeBilibiliVideoInfo(
data: BilibiliViewResponse['data'],
reference: BilibiliVideoReference,
): BilibiliVideoInfo {
return {
aid: readNumber(data?.aid, reference.kind === 'aid' ? Number(reference.value) : 0),
bvid: readText(data?.bvid, reference.kind === 'bvid' ? reference.value : ''),
desc: readText(data?.desc),
duration: readNumber(data?.duration),
ownerName: readText(data?.owner?.name, '未知UP主'),
pic: readText(data?.pic),
stat: {
danmaku: readNumber(data?.stat?.danmaku),
like: readNumber(data?.stat?.like),
view: readNumber(data?.stat?.view),
},
title: readText(data?.title, '未知标题'),
};
}
/**
* Converts arbitrary API text values to stable strings.
* @param value - Unknown text-like field from the Bilibili API response.
* @param fallback - Domain fallback used when the API field is absent or blank.
* @returns Trimmed string or the supplied fallback.
*/
function readText(value: unknown, fallback = '') {
const text = typeof value === 'string' ? value.trim() : '';
return text || fallback;
}
/**
* Converts arbitrary API number values to non-negative integers.
* @param value - Unknown numeric field from the Bilibili API response.
* @param fallback - Domain fallback used when the API field is missing or invalid.
* @returns Non-negative integer suitable for duration, id or stat counters.
*/
function readNumber(value: unknown, fallback = 0) {
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) && numberValue > 0
? Math.floor(numberValue)
: fallback;
}
/**
* Converts Bilibili API error payloads to readable Chinese messages.
* @param message - Raw `message` field returned by the Bilibili API.
* @returns Trimmed message or a stable fallback.
*/
function normalizeBilibiliMessage(message: unknown) {
return readText(message, '未知错误');
}

View File

@ -0,0 +1,169 @@
import { readBilibiliCardRuntimeConfig } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config';
import type { BilibiliCardPluginHost } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types';
import { formatBilibiliVideoReply } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter';
import { BilibiliVideoClient } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client';
describe('Bilibili video client', () => {
it('fetches a BV video through the plugin host and normalizes the response', async () => {
const host = createHost({
requestJson: jest.fn().mockResolvedValue({
code: 0,
data: {
aid: 170001,
bvid: 'BV1xx411c7mD',
desc: '第一行\n第二行',
duration: 125,
owner: { name: 'UP主' },
pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg',
stat: {
danmaku: 456,
like: 7890,
view: 123456,
},
title: '夏祭',
},
}),
});
const client = new BilibiliVideoClient(host);
await expect(
client.fetchVideo(
{
canonicalVideoId: 'BV1xx411c7mD',
kind: 'bvid',
sourceUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
value: 'BV1xx411c7mD',
},
readBilibiliCardRuntimeConfig(host),
),
).resolves.toMatchObject({
aid: 170001,
bvid: 'BV1xx411c7mD',
duration: 125,
ownerName: 'UP主',
title: '夏祭',
});
expect(host.requestJson).toHaveBeenCalledTimes(1);
const request = (host.requestJson as jest.Mock).mock.calls[0][0];
expect(request.url.toString()).toBe(
'https://api.bilibili.com/x/web-interface/view?bvid=BV1xx411c7mD',
);
expect(request.timeoutMs).toBe(6000);
expect(request.context).toBe('Bilibili 视频信息获取');
expect(request.failureMessage(502)).toBe(
'Bilibili 视频信息获取失败HTTP 502',
);
expect(request.invalidJsonMessage).toBe(
'Bilibili 视频信息返回不是合法 JSON',
);
});
it('throws a readable error when Bilibili returns an error code', async () => {
const host = createHost({
requestJson: jest.fn().mockResolvedValue({
code: -404,
message: '啥都木有',
}),
});
const client = new BilibiliVideoClient(host);
await expect(
client.fetchVideo(
{
canonicalVideoId: 'av170001',
kind: 'aid',
sourceUrl: 'https://m.bilibili.com/video/av170001',
value: '170001',
},
readBilibiliCardRuntimeConfig(host),
),
).rejects.toThrow('Bilibili 视频信息获取失败:啥都木有');
const request = (host.requestJson as jest.Mock).mock.calls[0][0];
expect(request.url.toString()).toBe(
'https://api.bilibili.com/x/web-interface/view?aid=170001',
);
});
it('formats a concise text reply without echoing short links', () => {
expect(
formatBilibiliVideoReply(
{
aid: 170001,
bvid: 'BV1xx411c7mD',
desc: '第一行\n第二行',
duration: 125,
ownerName: 'UP主',
pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg',
stat: {
danmaku: 456,
like: 7890,
view: 123456,
},
title: '夏祭',
},
{
dedupeTtlMs: 600000,
descMaxLength: 6,
httpTimeoutMs: 6000,
maxRedirects: 5,
},
),
).toBe(
[
'Bilibili 视频解析',
'标题:夏祭',
'UPUP主',
'时长02:05',
'播放12.3万 弹幕456 点赞7890',
'链接https://www.bilibili.com/video/BV1xx411c7mD',
'简介:第一行 第二…',
].join('\n'),
);
});
it('clamps runtime config values from the plugin host', () => {
const host = createHost({
/**
* Reads test config values by Bilibili card runtime key.
* @param key - Runtime config key requested by `readBilibiliCardRuntimeConfig`.
* @returns String value supplied by the current clamp scenario.
*/
getConfig: (<T = string>(key: string) => {
return {
QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS: '9999999',
QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH: '-1',
QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS: '10',
QQBOT_BILIBILI_CARD_MAX_REDIRECTS: '99',
}[key] as T | undefined;
}) as BilibiliCardPluginHost['getConfig'],
});
expect(readBilibiliCardRuntimeConfig(host)).toEqual({
dedupeTtlMs: 3600000,
descMaxLength: 0,
httpTimeoutMs: 1000,
maxRedirects: 10,
});
});
});
/**
* Builds the package-local Bilibili host contract used by client and config tests.
* @param overrides - Test doubles for host methods involved in the current assertion.
* @returns Host object with harmless defaults for unused plugin capabilities.
*/
function createHost(
overrides: Partial<BilibiliCardPluginHost> = {},
): BilibiliCardPluginHost {
return {
getBoundEventPluginKeys: jest.fn().mockResolvedValue([]),
getConfig: jest.fn(),
requestJson: jest.fn(),
resolveRedirect: jest.fn(),
sendText: jest.fn().mockResolvedValue(undefined),
warn: jest.fn(),
...overrides,
};
}