From 0782b1d6393faed75881de187d06aedc123dd2ea Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 17:40:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7Bilibili=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E9=93=BE=E6=8E=A5=E6=8F=90=E5=8F=96=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/domain/bilibili-url-extractor.ts | 190 ++++++++++++++---- .../src/domain/bilibili-url-parser.ts | 2 +- .../bilibili-url-extractor.spec.ts | 57 ++++++ 3 files changed, 210 insertions(+), 39 deletions(-) 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 index 9a66bb7..aaf358e 100644 --- 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 @@ -4,8 +4,20 @@ import { isAllowedBilibiliUrl, } from './bilibili-url-parser'; -const URL_PATTERN = /https?:\/\/[^\s<>"',。!?;、]+/giu; +const URL_PATTERN = /https?:\/\/[^\s<>"',;:!,。!?;、\]\)}]+/giu; const MAX_DEPTH = 7; +const MAX_NODES = 200; +const MAX_STRINGS = 200; +const MAX_STRING_LENGTH = 4000; +const MAX_URLS = 20; +const MAX_JSON_BYTES = 8000; +const URL_LIKE_KEY_PATTERN = /(?:^url$|url$|jump|qqdocurl)/iu; + +type ExtractionState = { + candidates: string[]; + nodes: number; + strings: number; +}; /** * Extracts Bilibili URL candidates from normalized message text and raw QQ card payloads. @@ -23,6 +35,7 @@ export function extractBilibiliUrls(input: BilibiliUrlExtractionInput) { if (!isAllowedBilibiliUrl(cleaned) || seen.has(cleaned)) continue; seen.add(cleaned); output.push(cleaned); + if (output.length >= MAX_URLS) return output; } } @@ -35,66 +48,124 @@ export function extractBilibiliUrls(input: BilibiliUrlExtractionInput) { * @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; + const state = createExtractionState(); + pushText(state, input.messageText); + pushText(state, input.rawMessage); + collectRawEventCandidates(input.rawEvent, state); + return state.candidates; } /** - * 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. + * Creates mutable extraction counters used to keep event traversal predictable. + * @returns Empty extraction state with bounded candidate storage. + */ +function createExtractionState(): ExtractionState { + return { + candidates: [], + nodes: 0, + strings: 0, + }; +} + +/** + * Collects raw event candidates from explicit OneBot message segments and URL-like fields. + * @param rawEvent - Raw OneBot event payload from NapCat. + * @param state - Mutable extraction state with traversal counters. + */ +function collectRawEventCandidates( + rawEvent: BilibiliUrlExtractionInput['rawEvent'], + state: ExtractionState, +) { + if (!isRecord(rawEvent)) return; + collectMessageSegments(rawEvent.message, state); + collectUrlLikeFields(rawEvent, state, new WeakSet(), 0); +} + +/** + * Collects candidates from OneBot message segment arrays or a single segment object. + * @param value - Raw `message` field from a OneBot event. + * @param state - Mutable extraction state with traversal counters. + */ +function collectMessageSegments(value: unknown, state: ExtractionState) { + if (Array.isArray(value)) { + for (const segment of value) { + collectMessageSegment(segment, state); + } + return; + } + collectMessageSegment(value, state); +} + +/** + * Collects URL-like fields and known card text payloads from one OneBot message segment. + * @param value - Raw OneBot segment value. + * @param state - Mutable extraction state with traversal counters. + */ +function collectMessageSegment(value: unknown, state: ExtractionState) { + if (!isRecord(value)) return; + const type = typeof value.type === 'string' ? value.type.toLowerCase() : ''; + const data = isRecord(value.data) ? value.data : undefined; + + collectUrlLikeFields(value, state, new WeakSet(), 0); + + if (!data) return; + if (type === 'json' || type === 'lightapp') { + pushText(state, data.data); + collectJsonCardPayload(data.data, state); + return; + } + if (type === 'xml') { + pushText(state, data.data); + } +} + +/** + * Walks object payloads while collecting only values held by URL-like keys. + * @param value - Unknown raw event value to inspect. + * @param state - Mutable extraction state with traversal counters. * @param seen - Object identity set used to avoid cyclic payloads. * @param depth - Current recursion depth used to bound malformed payloads. */ -function collectUnknown( +function collectUrlLikeFields( value: unknown, - output: string[], + state: ExtractionState, 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 (!enterObjectNode(value, state, seen, depth)) return; if (Array.isArray(value)) { for (const item of value) { - collectUnknown(item, output, seen, depth + 1); + collectUrlLikeFields(item, state, seen, depth + 1); } return; } - for (const nestedValue of Object.values(value)) { - collectUnknown(nestedValue, output, seen, depth + 1); + for (const [key, nestedValue] of Object.entries(value)) { + if (typeof nestedValue === 'string') { + if (isUrlLikeKey(key)) pushText(state, nestedValue); + continue; + } + collectUrlLikeFields(nestedValue, state, 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. + * @param value - Raw segment `data.data` value that may be JSON. + * @param state - Mutable extraction state with traversal counters. */ -function collectJsonString( - value: string, - output: string[], - seen: WeakSet, - depth: number, -) { +function collectJsonCardPayload(value: unknown, state: ExtractionState) { + if (typeof value !== 'string' || value.length > MAX_JSON_BYTES) return; const trimmed = value.trim(); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return; try { - collectUnknown(JSON.parse(trimmed), output, seen, depth); + collectUrlLikeFields( + JSON.parse(trimmed), + state, + new WeakSet(), + 0, + ); } catch { return; } @@ -102,11 +173,54 @@ function collectJsonString( /** * Adds a non-empty string candidate to the output list. - * @param output - Mutable candidate string list. + * @param state - Mutable extraction state with traversal counters. * @param value - Candidate value from normalized text or raw card payload. */ -function pushText(output: string[], value: unknown) { +function pushText(state: ExtractionState, value: unknown) { + if (state.strings >= MAX_STRINGS) return; if (typeof value === 'string' && value.trim()) { - output.push(value); + state.strings += 1; + state.candidates.push(value.slice(0, MAX_STRING_LENGTH)); } } + +/** + * Checks whether an unknown value is a non-null object record. + * @param value - Unknown value from the raw event tree. + * @returns `true` when the value can be inspected with object keys. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Enters an object node when recursion and node-count bounds allow it. + * @param value - Unknown value being traversed. + * @param state - Mutable extraction state with traversal counters. + * @param seen - Object identity set used to avoid cyclic payloads. + * @param depth - Current recursion depth used to bound malformed payloads. + * @returns `true` when callers may inspect the object's children. + */ +function enterObjectNode( + value: unknown, + state: ExtractionState, + seen: WeakSet, + depth: number, +): value is Record | unknown[] { + if (depth > MAX_DEPTH || !isRecord(value) || state.nodes >= MAX_NODES) { + return false; + } + if (seen.has(value)) return false; + seen.add(value); + state.nodes += 1; + return true; +} + +/** + * Identifies card fields that commonly carry jump URLs instead of display text. + * @param key - Raw object key from a OneBot segment or card payload. + * @returns `true` when the key name suggests URL content. + */ +function isUrlLikeKey(key: string) { + return URL_LIKE_KEY_PATTERN.test(key); +} 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 index d8514f0..c234be3 100644 --- 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 @@ -2,7 +2,7 @@ import type { BilibiliVideoReference } from './bilibili-card.types'; const BILIBILI_HOST = 'bilibili.com'; const B23_HOST = 'b23.tv'; -const TRAILING_WRAPPERS = /[\s"'<>,。!?、;:))\]}】]+$/u; +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+)$/; 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 index 0b2045e..8859507 100644 --- a/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts @@ -78,4 +78,61 @@ describe('Bilibili URL extractor', () => { }), ).toEqual(['https://m.bilibili.com/video/av170001']); }); + + it('stops URL tokens at CQ separators and ASCII punctuation', () => { + expect( + extractBilibiliUrls({ + messageText: + '[CQ:share,url=https://b23.tv/abc123,title=视频] https://www.bilibili.com/video/BV1xx411c7mD.', + rawEvent: {}, + rawMessage: '', + }), + ).toEqual([ + 'https://b23.tv/abc123', + 'https://www.bilibili.com/video/BV1xx411c7mD', + ]); + }); + + it('does not scan unrelated rawEvent string fields', () => { + expect( + extractBilibiliUrls({ + messageText: '', + rawMessage: '', + rawEvent: { + debug: 'https://www.bilibili.com/video/BV1xx411c7mD', + message: [], + }, + }), + ).toEqual([]); + }); + + it('keeps exact cleaned URL dedupe while preserving distinct query URLs', () => { + expect( + extractBilibiliUrls({ + messageText: + 'https://b23.tv/abc123 https://b23.tv/abc123 https://b23.tv/abc123?share=qq', + rawEvent: {}, + rawMessage: 'https://b23.tv/abc123', + }), + ).toEqual(['https://b23.tv/abc123', 'https://b23.tv/abc123?share=qq']); + }); + + it('handles invalid JSON card strings without aborting extraction', () => { + expect( + extractBilibiliUrls({ + messageText: '', + rawMessage: '', + rawEvent: { + message: [ + { + data: { + data: '{"jumpUrl":"https://b23.tv/abc123"', + }, + type: 'json', + }, + ], + }, + }), + ).toEqual(['https://b23.tv/abc123']); + }); });