fix: 收紧Bilibili卡片链接提取边界
This commit is contained in:
parent
a7ceae8f58
commit
0782b1d639
@ -4,8 +4,20 @@ import {
|
|||||||
isAllowedBilibiliUrl,
|
isAllowedBilibiliUrl,
|
||||||
} from './bilibili-url-parser';
|
} from './bilibili-url-parser';
|
||||||
|
|
||||||
const URL_PATTERN = /https?:\/\/[^\s<>"',。!?;、]+/giu;
|
const URL_PATTERN = /https?:\/\/[^\s<>"',;:!,。!?;、\]\)}]+/giu;
|
||||||
const MAX_DEPTH = 7;
|
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.
|
* 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;
|
if (!isAllowedBilibiliUrl(cleaned) || seen.has(cleaned)) continue;
|
||||||
seen.add(cleaned);
|
seen.add(cleaned);
|
||||||
output.push(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.
|
* @returns Candidate strings that may contain URLs.
|
||||||
*/
|
*/
|
||||||
function collectStringCandidates(input: BilibiliUrlExtractionInput) {
|
function collectStringCandidates(input: BilibiliUrlExtractionInput) {
|
||||||
const output: string[] = [];
|
const state = createExtractionState();
|
||||||
const seen = new WeakSet<object>();
|
pushText(state, input.messageText);
|
||||||
pushText(output, input.messageText);
|
pushText(state, input.rawMessage);
|
||||||
pushText(output, input.rawMessage);
|
collectRawEventCandidates(input.rawEvent, state);
|
||||||
collectUnknown(input.rawEvent, output, seen, 0);
|
return state.candidates;
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks unknown card payload data while bounding recursion and parsing JSON-looking strings.
|
* Creates mutable extraction counters used to keep event traversal predictable.
|
||||||
* @param value - Unknown raw value from OneBot message segments or nested card fields.
|
* @returns Empty extraction state with bounded candidate storage.
|
||||||
* @param output - Mutable candidate string list.
|
*/
|
||||||
|
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<object>(), 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<object>(), 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 seen - Object identity set used to avoid cyclic payloads.
|
||||||
* @param depth - Current recursion depth used to bound malformed payloads.
|
* @param depth - Current recursion depth used to bound malformed payloads.
|
||||||
*/
|
*/
|
||||||
function collectUnknown(
|
function collectUrlLikeFields(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
output: string[],
|
state: ExtractionState,
|
||||||
seen: WeakSet<object>,
|
seen: WeakSet<object>,
|
||||||
depth: number,
|
depth: number,
|
||||||
) {
|
) {
|
||||||
if (depth > MAX_DEPTH || value == null) return;
|
if (!enterObjectNode(value, state, seen, depth)) 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)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
collectUnknown(item, output, seen, depth + 1);
|
collectUrlLikeFields(item, state, seen, depth + 1);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const nestedValue of Object.values(value)) {
|
for (const [key, nestedValue] of Object.entries(value)) {
|
||||||
collectUnknown(nestedValue, output, seen, depth + 1);
|
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.
|
* Parses a JSON card string when possible and ignores invalid JSON without aborting extraction.
|
||||||
* @param value - Raw string that may be JSON.
|
* @param value - Raw segment `data.data` value that may be JSON.
|
||||||
* @param output - Mutable candidate string list.
|
* @param state - Mutable extraction state with traversal counters.
|
||||||
* @param seen - Object identity set shared with the recursive walk.
|
|
||||||
* @param depth - Recursion depth for the parsed value.
|
|
||||||
*/
|
*/
|
||||||
function collectJsonString(
|
function collectJsonCardPayload(value: unknown, state: ExtractionState) {
|
||||||
value: string,
|
if (typeof value !== 'string' || value.length > MAX_JSON_BYTES) return;
|
||||||
output: string[],
|
|
||||||
seen: WeakSet<object>,
|
|
||||||
depth: number,
|
|
||||||
) {
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return;
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return;
|
||||||
try {
|
try {
|
||||||
collectUnknown(JSON.parse(trimmed), output, seen, depth);
|
collectUrlLikeFields(
|
||||||
|
JSON.parse(trimmed),
|
||||||
|
state,
|
||||||
|
new WeakSet<object>(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -102,11 +173,54 @@ function collectJsonString(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a non-empty string candidate to the output list.
|
* 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.
|
* @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()) {
|
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<string, unknown> {
|
||||||
|
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<object>,
|
||||||
|
depth: number,
|
||||||
|
): value is Record<string, unknown> | 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);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { BilibiliVideoReference } from './bilibili-card.types';
|
|||||||
|
|
||||||
const BILIBILI_HOST = 'bilibili.com';
|
const BILIBILI_HOST = 'bilibili.com';
|
||||||
const B23_HOST = 'b23.tv';
|
const B23_HOST = 'b23.tv';
|
||||||
const TRAILING_WRAPPERS = /[\s"'<>,。!?、;:))\]}】]+$/u;
|
const TRAILING_WRAPPERS = /[\s"'<>.,!?;:,。!?、;:))\]}】]+$/u;
|
||||||
const LEADING_WRAPPERS = /^[\s"'<>(([{【]+/u;
|
const LEADING_WRAPPERS = /^[\s"'<>(([{【]+/u;
|
||||||
const BVID_PATTERN = /^BV[0-9A-Za-z]{10}$/;
|
const BVID_PATTERN = /^BV[0-9A-Za-z]{10}$/;
|
||||||
const AID_PATTERN = /^(?:av|AV)(\d+)$/;
|
const AID_PATTERN = /^(?:av|AV)(\d+)$/;
|
||||||
|
|||||||
@ -78,4 +78,61 @@ describe('Bilibili URL extractor', () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual(['https://m.bilibili.com/video/av170001']);
|
).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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user