From a7ceae8f581bea9baca78fad99af08e934722150 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 17:20:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E5=8F=96QQ=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=AD=E7=9A=84Bilibili=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/domain/bilibili-card.types.ts | 6 + .../src/domain/bilibili-url-extractor.ts | 112 ++++++++++++++++++ .../bilibili-url-extractor.spec.ts | 81 +++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts create mode 100644 test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts 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 index 9e5b6bf..6c3eac1 100644 --- 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 @@ -11,3 +11,9 @@ export type BilibiliVideoReference = sourceUrl: string; value: string; }; + +export type BilibiliUrlExtractionInput = { + messageText?: string; + rawEvent?: Record; + rawMessage?: string; +}; diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts new file mode 100644 index 0000000..9a66bb7 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts @@ -0,0 +1,112 @@ +import type { BilibiliUrlExtractionInput } from './bilibili-card.types'; +import { + cleanBilibiliUrlCandidate, + isAllowedBilibiliUrl, +} from './bilibili-url-parser'; + +const URL_PATTERN = /https?:\/\/[^\s<>"',。!?;、]+/giu; +const MAX_DEPTH = 7; + +/** + * Extracts Bilibili URL candidates from normalized message text and raw QQ card payloads. + * @param input - Normalized QQBot message fields and raw OneBot event payload from NapCat. + * @returns Unique allowed Bilibili URL strings in discovery order. + */ +export function extractBilibiliUrls(input: BilibiliUrlExtractionInput) { + const candidates = collectStringCandidates(input); + const seen = new Set(); + const output: string[] = []; + + for (const text of candidates) { + for (const rawUrl of text.match(URL_PATTERN) || []) { + const cleaned = cleanBilibiliUrlCandidate(rawUrl); + if (!isAllowedBilibiliUrl(cleaned) || seen.has(cleaned)) continue; + seen.add(cleaned); + output.push(cleaned); + } + } + + return output; +} + +/** + * Collects string values that may contain links from text fields and nested QQ card objects. + * @param input - Extraction input built from normalized message state. + * @returns Candidate strings that may contain URLs. + */ +function collectStringCandidates(input: BilibiliUrlExtractionInput) { + const output: string[] = []; + const seen = new WeakSet(); + pushText(output, input.messageText); + pushText(output, input.rawMessage); + collectUnknown(input.rawEvent, output, seen, 0); + return output; +} + +/** + * Walks unknown card payload data while bounding recursion and parsing JSON-looking strings. + * @param value - Unknown raw value from OneBot message segments or nested card fields. + * @param output - Mutable candidate string list. + * @param seen - Object identity set used to avoid cyclic payloads. + * @param depth - Current recursion depth used to bound malformed payloads. + */ +function collectUnknown( + value: unknown, + output: string[], + seen: WeakSet, + depth: number, +) { + if (depth > MAX_DEPTH || value == null) return; + if (typeof value === 'string') { + pushText(output, value); + collectJsonString(value, output, seen, depth + 1); + return; + } + if (typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + collectUnknown(item, output, seen, depth + 1); + } + return; + } + + for (const nestedValue of Object.values(value)) { + collectUnknown(nestedValue, output, seen, depth + 1); + } +} + +/** + * Parses a JSON card string when possible and ignores invalid JSON without aborting extraction. + * @param value - Raw string that may be JSON. + * @param output - Mutable candidate string list. + * @param seen - Object identity set shared with the recursive walk. + * @param depth - Recursion depth for the parsed value. + */ +function collectJsonString( + value: string, + output: string[], + seen: WeakSet, + depth: number, +) { + const trimmed = value.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return; + try { + collectUnknown(JSON.parse(trimmed), output, seen, depth); + } catch { + return; + } +} + +/** + * Adds a non-empty string candidate to the output list. + * @param output - Mutable candidate string list. + * @param value - Candidate value from normalized text or raw card payload. + */ +function pushText(output: string[], value: unknown) { + if (typeof value === 'string' && value.trim()) { + output.push(value); + } +} diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts new file mode 100644 index 0000000..0b2045e --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts @@ -0,0 +1,81 @@ +import { extractBilibiliUrls } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor'; + +describe('Bilibili URL extractor', () => { + it('extracts links from messageText and rawMessage while deduplicating them', () => { + expect( + extractBilibiliUrls({ + messageText: '看看 https://www.bilibili.com/video/BV1xx411c7mD', + rawMessage: + '重复 https://www.bilibili.com/video/BV1xx411c7mD?share=qq', + rawEvent: {}, + }), + ).toEqual([ + 'https://www.bilibili.com/video/BV1xx411c7mD', + 'https://www.bilibili.com/video/BV1xx411c7mD?share=qq', + ]); + }); + + it('extracts a QQ share card URL', () => { + const urls = extractBilibiliUrls({ + messageText: '', + rawMessage: '', + rawEvent: { + message: [ + { + data: { + content: '夏祭 视频', + title: 'Bilibili', + url: 'https://www.bilibili.com/video/BV1xx411c7mD', + }, + type: 'share', + }, + ], + }, + }); + + expect(urls).toEqual(['https://www.bilibili.com/video/BV1xx411c7mD']); + }); + + it('extracts nested URLs from json and lightapp cards', () => { + const payload = JSON.stringify({ + app: 'com.tencent.structmsg', + meta: { + detail: { + jumpUrl: 'https://b23.tv/abc123', + }, + }, + }); + + expect( + extractBilibiliUrls({ + messageText: '', + rawMessage: '', + rawEvent: { + message: [ + { data: { data: payload }, type: 'json' }, + { data: { data: payload }, type: 'lightapp' }, + ], + }, + }), + ).toEqual(['https://b23.tv/abc123']); + }); + + it('extracts URLs from xml card text and ignores non-Bilibili URLs', () => { + expect( + extractBilibiliUrls({ + messageText: 'https://example.com/video/BV1xx411c7mD', + rawMessage: '', + rawEvent: { + message: [ + { + data: { + data: '', + }, + type: 'xml', + }, + ], + }, + }), + ).toEqual(['https://m.bilibili.com/video/av170001']); + }); +});