feat: 添加Bilibili链接解析域逻辑

This commit is contained in:
sunlei 2026-06-19 16:35:26 +08:00
parent f8cd07e018
commit 7522b4c2b2
3 changed files with 139 additions and 0 deletions

View File

@ -0,0 +1,13 @@
export type BilibiliVideoReference =
| {
canonicalVideoId: string;
kind: 'bvid';
sourceUrl: string;
value: string;
}
| {
canonicalVideoId: string;
kind: 'aid';
sourceUrl: string;
value: string;
};

View File

@ -0,0 +1,79 @@
import type { BilibiliVideoReference } from './bilibili-card.types';
const BILIBILI_HOST = 'bilibili.com';
const B23_HOST = 'b23.tv';
const TRAILING_WRAPPERS = /[\s"'<>)\]}]+$/u;
const LEADING_WRAPPERS = /^[\s"'<>([{]+/u;
const BVID_PATTERN = /(?:^|\/)(BV[0-9A-Za-z]{10,})/;
const AID_PATTERN = /(?:^|\/)(?:av|AV)(\d+)(?:$|[/?#])/;
/**
* Removes QQ card wrappers and punctuation that often stick to copied URLs.
* @param candidate - Raw string fragment found in text or card JSON.
* @returns Cleaned URL candidate ready for `URL` parsing.
*/
export function cleanBilibiliUrlCandidate(candidate: string) {
return candidate
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replace(LEADING_WRAPPERS, '')
.replace(TRAILING_WRAPPERS, '')
.trim();
}
/**
* Checks whether a URL belongs to the Bilibili domains this plugin is allowed to parse.
* @param candidate - URL string collected from a QQ message or redirect result.
* @returns `true` when the host is Bilibili-owned or `b23.tv`.
*/
export function isAllowedBilibiliUrl(candidate: string) {
try {
const url = new URL(cleanBilibiliUrlCandidate(candidate));
const hostname = url.hostname.toLowerCase();
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
(hostname === B23_HOST ||
hostname === BILIBILI_HOST ||
hostname.endsWith(`.${BILIBILI_HOST}`))
);
} catch {
return false;
}
}
/**
* Extracts a Bilibili video identifier from a supported URL.
* @param candidate - Direct Bilibili video URL or short-link URL that already embeds BV/av.
* @returns Parsed video reference, or `null` when the URL is not a supported video URL.
*/
export function parseBilibiliVideoReference(
candidate: string,
): BilibiliVideoReference | null {
const cleaned = cleanBilibiliUrlCandidate(candidate);
if (!isAllowedBilibiliUrl(cleaned)) return null;
const url = new URL(cleaned);
const probe = `${url.pathname}${url.search}${url.hash}`;
const bvid = probe.match(BVID_PATTERN)?.[1];
if (bvid) {
return {
canonicalVideoId: bvid,
kind: 'bvid',
sourceUrl: cleaned,
value: bvid,
};
}
const aid = `${url.pathname}/`.match(AID_PATTERN)?.[1];
if (aid) {
return {
canonicalVideoId: `av${aid}`,
kind: 'aid',
sourceUrl: cleaned,
value: aid,
};
}
return null;
}

View File

@ -0,0 +1,47 @@
import {
cleanBilibiliUrlCandidate,
isAllowedBilibiliUrl,
parseBilibiliVideoReference,
} from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser';
describe('Bilibili URL parser', () => {
it('parses BV video URLs while ignoring query, hash, and trailing punctuation', () => {
const reference = parseBilibiliVideoReference(
'https://www.bilibili.com/video/BV1xx411c7mD/?share_source=qq#reply。',
);
expect(reference).toEqual({
canonicalVideoId: 'BV1xx411c7mD',
kind: 'bvid',
sourceUrl:
'https://www.bilibili.com/video/BV1xx411c7mD/?share_source=qq#reply',
value: 'BV1xx411c7mD',
});
});
it('parses av video URLs from mobile Bilibili links', () => {
expect(
parseBilibiliVideoReference('https://m.bilibili.com/video/av170001'),
).toMatchObject({
canonicalVideoId: 'av170001',
kind: 'aid',
value: '170001',
});
});
it('allows only Bilibili and b23.tv hosts', () => {
expect(isAllowedBilibiliUrl('https://b23.tv/abc123')).toBe(true);
expect(isAllowedBilibiliUrl('https://space.bilibili.com/1')).toBe(true);
expect(isAllowedBilibiliUrl('https://example.com/video/BV1xx411c7mD')).toBe(
false,
);
});
it('cleans card wrappers, html entities, and trailing brackets', () => {
expect(
cleanBilibiliUrlCandidate(
'&quot;https://www.bilibili.com/video/BV1xx411c7mD?p=1&quot;',
),
).toBe('https://www.bilibili.com/video/BV1xx411c7mD?p=1');
});
});