feat: 提取QQ卡片中的Bilibili链接
This commit is contained in:
parent
69cf6d99c4
commit
a7ceae8f58
@ -11,3 +11,9 @@ export type BilibiliVideoReference =
|
|||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BilibiliUrlExtractionInput = {
|
||||||
|
messageText?: string;
|
||||||
|
rawEvent?: Record<string, unknown>;
|
||||||
|
rawMessage?: string;
|
||||||
|
};
|
||||||
|
|||||||
@ -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<string>();
|
||||||
|
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<object>();
|
||||||
|
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<object>,
|
||||||
|
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<object>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: '<msg url="https://m.bilibili.com/video/av170001" />',
|
||||||
|
},
|
||||||
|
type: 'xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual(['https://m.bilibili.com/video/av170001']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user