diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts new file mode 100644 index 0000000..9e5b6bf --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts @@ -0,0 +1,13 @@ +export type BilibiliVideoReference = + | { + canonicalVideoId: string; + kind: 'bvid'; + sourceUrl: string; + value: string; + } + | { + canonicalVideoId: string; + kind: 'aid'; + sourceUrl: string; + value: string; + }; diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts new file mode 100644 index 0000000..ce21357 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts @@ -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('&', '&') + .replaceAll('"', '"') + .replaceAll('"', '"') + .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; +} diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts new file mode 100644 index 0000000..03a7445 --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts @@ -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( + '"https://www.bilibili.com/video/BV1xx411c7mD?p=1")', + ), + ).toBe('https://www.bilibili.com/video/BV1xx411c7mD?p=1'); + }); +});