mioku-plugin-media 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ import type { Platform, ParsedMediaUrl } from "./types";
2
+
3
+ const BILIBILI_DOMAINS = [
4
+ "bilibili.com",
5
+ "www.bilibili.com",
6
+ "m.bilibili.com",
7
+ "b23.tv",
8
+ "t.bilibili.com",
9
+ "bili2233.cn",
10
+ ];
11
+
12
+ const DOUYIN_DOMAINS = [
13
+ "douyin.com",
14
+ "www.douyin.com",
15
+ "v.douyin.com",
16
+ "iesdouyin.com",
17
+ "www.iesdouyin.com",
18
+ "jx.douyin.com",
19
+ "m.douyin.com",
20
+ "jingxuan.douyin.com",
21
+ ];
22
+
23
+ const KUAISHOU_DOMAINS = ["kuaishou.com", "www.kuaishou.com", "v.kuaishou.com"];
24
+
25
+ const XIAOHONGSHU_DOMAINS = [
26
+ "xiaohongshu.com",
27
+ "www.xiaohongshu.com",
28
+ "xhslink.com",
29
+ ];
30
+
31
+ const BV_REGEX = /\b(BV[a-zA-Z0-9]{10,})\b/;
32
+ const AV_REGEX = /\b(av(\d+))\b/i;
33
+ const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi;
34
+ const DOUYIN_ID_REGEX = /\/video\/(\d+)/;
35
+ const KUAISHOU_ID_REGEX = /\/short-video\/([a-zA-Z0-9_-]+)/;
36
+ const XHS_NOTE_REGEX = /\/explore\/([a-f0-9]{24})/;
37
+ const XHS_DISCOVERY_REGEX = /\/discovery\/item\/([a-f0-9]{24})/;
38
+ const XHSLINK_NOTE_REGEX = /\/([a-f0-9]{24})/;
39
+
40
+ const KUAISHOU_SHARE_REGEX = /快手[^\n]*快手/;
41
+
42
+ function extractDomain(url: string): string {
43
+ try {
44
+ const parsed = new URL(url);
45
+ return parsed.hostname.toLowerCase();
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ function extractB23UrlFromJsonCard(jsonStr: string): string | null {
52
+ try {
53
+ const json = JSON.parse(jsonStr);
54
+ const meta = json.meta;
55
+ if (meta) {
56
+ for (const key of Object.keys(meta)) {
57
+ const detail = meta[key];
58
+ if (detail.qqdocurl && typeof detail.qqdocurl === "string") {
59
+ return detail.qqdocurl;
60
+ }
61
+ if (detail.qqdocUrl && typeof detail.qqdocUrl === "string") {
62
+ return detail.qqdocUrl;
63
+ }
64
+ if (detail.url && typeof detail.url === "string") {
65
+ const url = detail.url;
66
+ if (url.includes("b23.tv") || url.includes("bilibili.com")) {
67
+ return url;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ if (json.prompt) {
73
+ const urlMatch = json.prompt.match(URL_REGEX);
74
+ if (urlMatch) {
75
+ const url = urlMatch[0];
76
+ if (url.includes("b23.tv") || url.includes("bilibili.com")) {
77
+ return url;
78
+ }
79
+ }
80
+ }
81
+ } catch {
82
+ // not valid JSON
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function matchDomain(hostname: string, domains: string[]): boolean {
88
+ return domains.some((d) => hostname === d || hostname.endsWith("." + d));
89
+ }
90
+
91
+ function parseBilibiliUrl(url: string): ParsedMediaUrl | null {
92
+ const hostname = extractDomain(url);
93
+
94
+ if (hostname.includes("b23.tv")) {
95
+ return { platform: "bilibili", id: url, subtype: "video" };
96
+ }
97
+
98
+ if (hostname.includes("t.bilibili.com")) {
99
+ const match = url.match(/\/(\d+)/);
100
+ if (match) {
101
+ return { platform: "bilibili", id: match[1], subtype: "article" };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ const bvMatch = url.match(BV_REGEX);
107
+ if (bvMatch) {
108
+ return { platform: "bilibili", id: bvMatch[1], subtype: "video" };
109
+ }
110
+
111
+ const avMatch = url.match(AV_REGEX);
112
+ if (avMatch) {
113
+ return { platform: "bilibili", id: avMatch[1], subtype: "video" };
114
+ }
115
+
116
+ const pathMatch = url.match(/\/video\/(BV[a-zA-Z0-9]+|av\d+)/i);
117
+ if (pathMatch) {
118
+ return { platform: "bilibili", id: pathMatch[1], subtype: "video" };
119
+ }
120
+
121
+ if (matchDomain(hostname, BILIBILI_DOMAINS)) {
122
+ const bangumiMatch = url.match(/\/bangumi\/play\/(ep\d+|ss\d+)/);
123
+ if (bangumiMatch) {
124
+ return {
125
+ platform: "bilibili",
126
+ id: bangumiMatch[1],
127
+ subtype: "bangumi",
128
+ extra: { bangumiId: bangumiMatch[1] },
129
+ };
130
+ }
131
+
132
+ const articleMatch = url.match(/\/read\/(cv\d+)/i);
133
+ if (articleMatch) {
134
+ return { platform: "bilibili", id: articleMatch[1], subtype: "article" };
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function parseDouyinUrl(url: string): ParsedMediaUrl | null {
142
+ const hostname = extractDomain(url);
143
+ if (!matchDomain(hostname, DOUYIN_DOMAINS)) return null;
144
+
145
+ const idMatch = url.match(DOUYIN_ID_REGEX);
146
+ if (idMatch) {
147
+ return { platform: "douyin", id: idMatch[1], subtype: "video" };
148
+ }
149
+
150
+ const noteMatch = url.match(/\/note\/(\d+)/);
151
+ if (noteMatch) {
152
+ return { platform: "douyin", id: noteMatch[1], subtype: "video" };
153
+ }
154
+
155
+ const modalMatch = url.match(/modal_id=(\d+)/);
156
+ if (modalMatch) {
157
+ return { platform: "douyin", id: modalMatch[1], subtype: "video" };
158
+ }
159
+
160
+ return { platform: "douyin", id: url, subtype: "video" };
161
+ }
162
+
163
+ function parseKuaishouUrl(url: string): ParsedMediaUrl | null {
164
+ const hostname = extractDomain(url);
165
+ if (!matchDomain(hostname, KUAISHOU_DOMAINS)) return null;
166
+
167
+ const idMatch = url.match(KUAISHOU_ID_REGEX);
168
+ if (idMatch) {
169
+ return { platform: "kuaishou", id: idMatch[1], subtype: "video" };
170
+ }
171
+
172
+ const shortMatch = url.match(/\/short-video\/([a-zA-Z0-9_-]+)/);
173
+ if (shortMatch) {
174
+ return { platform: "kuaishou", id: shortMatch[1], subtype: "video" };
175
+ }
176
+
177
+ const photoMatch = url.match(/photo\/(\d+)/);
178
+ if (photoMatch) {
179
+ return { platform: "kuaishou", id: photoMatch[1], subtype: "video" };
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ function parseXiaohongshuUrl(url: string): ParsedMediaUrl | null {
186
+ const hostname = extractDomain(url);
187
+ if (!matchDomain(hostname, XIAOHONGSHU_DOMAINS)) return null;
188
+
189
+ const exploreMatch = url.match(XHS_NOTE_REGEX);
190
+ if (exploreMatch) {
191
+ const xsecToken = extractXsecToken(url);
192
+ return {
193
+ platform: "xiaohongshu",
194
+ id: exploreMatch[1],
195
+ subtype: "note",
196
+ extra: xsecToken ? { xsec_token: xsecToken } : undefined,
197
+ };
198
+ }
199
+
200
+ const discoveryMatch = url.match(XHS_DISCOVERY_REGEX);
201
+ if (discoveryMatch) {
202
+ const xsecToken = extractXsecToken(url);
203
+ return {
204
+ platform: "xiaohongshu",
205
+ id: discoveryMatch[1],
206
+ subtype: "note",
207
+ extra: xsecToken ? { xsec_token: xsecToken } : undefined,
208
+ };
209
+ }
210
+
211
+ if (hostname.includes("xhslink.com")) {
212
+ return { platform: "xiaohongshu", id: url, subtype: "note" };
213
+ }
214
+
215
+ const fallbackMatch = url.match(XHSLINK_NOTE_REGEX);
216
+ if (fallbackMatch && fallbackMatch[1].length === 24) {
217
+ const xsecToken = extractXsecToken(url);
218
+ return {
219
+ platform: "xiaohongshu",
220
+ id: fallbackMatch[1],
221
+ subtype: "note",
222
+ extra: xsecToken ? { xsec_token: xsecToken } : undefined,
223
+ };
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ function extractXsecToken(url: string): string | null {
230
+ try {
231
+ const parsed = new URL(url);
232
+ return (
233
+ parsed.searchParams.get("xsec_token") ||
234
+ parsed.searchParams.get("xsec_token")
235
+ );
236
+ } catch {
237
+ const match = url.match(/xsec_token=([^&]+)/);
238
+ return match ? match[1] : null;
239
+ }
240
+ }
241
+
242
+ function parseKuaishouShareText(text: string): ParsedMediaUrl | null {
243
+ if (!KUAISHOU_SHARE_REGEX.test(text)) return null;
244
+
245
+ const urlMatch = text.match(URL_REGEX);
246
+ if (urlMatch) {
247
+ return parseKuaishouUrl(urlMatch[0]);
248
+ }
249
+
250
+ return { platform: "kuaishou", id: text, subtype: "video" };
251
+ }
252
+
253
+ function parseJsonCardMessage(jsonStr: string): ParsedMediaUrl | null {
254
+ const extractedUrl = extractB23UrlFromJsonCard(jsonStr);
255
+ if (extractedUrl) {
256
+ const hostname = extractDomain(extractedUrl);
257
+
258
+ if (hostname.includes("b23.tv") || hostname.includes("bilibili.com")) {
259
+ return parseBilibiliUrl(extractedUrl);
260
+ }
261
+
262
+ if (matchDomain(hostname, DOUYIN_DOMAINS)) {
263
+ return parseDouyinUrl(extractedUrl);
264
+ }
265
+ if (matchDomain(hostname, KUAISHOU_DOMAINS)) {
266
+ return parseKuaishouUrl(extractedUrl);
267
+ }
268
+ if (matchDomain(hostname, XIAOHONGSHU_DOMAINS)) {
269
+ return parseXiaohongshuUrl(extractedUrl);
270
+ }
271
+
272
+ return { platform: "bilibili", id: extractedUrl, subtype: "video" };
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ export function parseMediaUrl(text: string): ParsedMediaUrl | null {
279
+ const trimmed = text.trim();
280
+ if (!trimmed) return null;
281
+
282
+ const bvMatch = trimmed.match(BV_REGEX);
283
+ if (bvMatch && !trimmed.includes("http")) {
284
+ return { platform: "bilibili", id: bvMatch[1], subtype: "video" };
285
+ }
286
+
287
+ const avMatch = trimmed.match(AV_REGEX);
288
+ if (avMatch && !trimmed.includes("http")) {
289
+ return { platform: "bilibili", id: avMatch[1], subtype: "video" };
290
+ }
291
+
292
+ const urls = trimmed.match(URL_REGEX);
293
+ if (urls) {
294
+ for (const url of urls) {
295
+ const hostname = extractDomain(url);
296
+
297
+ if (matchDomain(hostname, BILIBILI_DOMAINS)) {
298
+ return parseBilibiliUrl(url);
299
+ }
300
+ if (matchDomain(hostname, DOUYIN_DOMAINS)) {
301
+ return parseDouyinUrl(url);
302
+ }
303
+ if (matchDomain(hostname, KUAISHOU_DOMAINS)) {
304
+ return parseKuaishouUrl(url);
305
+ }
306
+ if (matchDomain(hostname, XIAOHONGSHU_DOMAINS)) {
307
+ return parseXiaohongshuUrl(url);
308
+ }
309
+ }
310
+ }
311
+
312
+ const kuaishouShare = parseKuaishouShareText(trimmed);
313
+ if (kuaishouShare) return kuaishouShare;
314
+
315
+ return null;
316
+ }
317
+
318
+ export function extractMediaUrlFromEvent(event: any): ParsedMediaUrl | null {
319
+ const rawText =
320
+ typeof event.raw_message === "string" ? event.raw_message.trim() : "";
321
+ if (rawText) {
322
+ const parsed = parseMediaUrl(rawText);
323
+ if (parsed) return parsed;
324
+
325
+ if (rawText.startsWith("{json:")) {
326
+ const jsonStart = rawText.indexOf("{", 0);
327
+ const jsonEnd = rawText.lastIndexOf("}");
328
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
329
+ const jsonStr = rawText.slice(jsonStart, jsonEnd + 1);
330
+ const jsonCard = parseJsonCardMessage(jsonStr);
331
+ if (jsonCard) return jsonCard;
332
+ }
333
+ }
334
+ }
335
+
336
+ const message = event.message;
337
+ if (Array.isArray(message)) {
338
+ for (const segment of message) {
339
+ if (segment.type === "json") {
340
+ let jsonStr = "";
341
+ if (typeof segment.data === "string") {
342
+ jsonStr = segment.data;
343
+ } else if (segment.data?.data) {
344
+ jsonStr = segment.data.data;
345
+ } else if (typeof segment.data === "object") {
346
+ jsonStr = JSON.stringify(segment.data);
347
+ }
348
+ if (jsonStr) {
349
+ const jsonCard = parseJsonCardMessage(jsonStr);
350
+ if (jsonCard) return jsonCard;
351
+ }
352
+ }
353
+
354
+ if (segment.type === "text" && segment.data?.text) {
355
+ const parsed = parseMediaUrl(segment.data.text);
356
+ if (parsed) return parsed;
357
+ }
358
+ }
359
+ }
360
+
361
+ return null;
362
+ }
363
+
364
+ export function resolveShortUrl(url: string): Promise<string> {
365
+ return new Promise((resolve) => {
366
+ try {
367
+ const controller = new AbortController();
368
+ const timeout = setTimeout(() => {
369
+ controller.abort();
370
+ resolve(url);
371
+ }, 5000);
372
+
373
+ fetch(url, {
374
+ redirect: "follow",
375
+ signal: controller.signal,
376
+ method: "HEAD",
377
+ })
378
+ .then((response) => {
379
+ clearTimeout(timeout);
380
+ resolve(response.url || url);
381
+ })
382
+ .catch(() => {
383
+ clearTimeout(timeout);
384
+ resolve(url);
385
+ });
386
+ } catch {
387
+ resolve(url);
388
+ }
389
+ });
390
+ }
391
+
392
+ export function isShortUrl(parsed: ParsedMediaUrl): boolean {
393
+ if (parsed.platform === "bilibili" && parsed.id.startsWith("http")) {
394
+ return true;
395
+ }
396
+ if (parsed.platform === "xiaohongshu" && parsed.id.includes("xhslink.com")) {
397
+ return true;
398
+ }
399
+ if (parsed.platform === "douyin" && parsed.id.startsWith("http")) {
400
+ return true;
401
+ }
402
+ return false;
403
+ }
package/runtime.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { MediaRuntimeState } from "./types";
2
+ import {
3
+ getPluginRuntimeState,
4
+ resetPluginRuntimeState,
5
+ setPluginRuntimeState,
6
+ } from "../../src";
7
+
8
+ const PLUGIN_NAME = "media";
9
+
10
+ export function setMediaRuntimeState(
11
+ nextState: Partial<MediaRuntimeState>,
12
+ ): MediaRuntimeState {
13
+ return setPluginRuntimeState<MediaRuntimeState>(PLUGIN_NAME, nextState);
14
+ }
15
+
16
+ export function getMediaRuntimeState(): MediaRuntimeState {
17
+ return getPluginRuntimeState<MediaRuntimeState>(PLUGIN_NAME);
18
+ }
19
+
20
+ export function resetMediaRuntimeState(): void {
21
+ resetPluginRuntimeState(PLUGIN_NAME);
22
+ }
package/skills.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { AISkill, AITool } from "../../src";
2
+ import type { SendNodeContentElement } from "napcat-sdk";
3
+ import { getMediaRuntimeState } from "./runtime";
4
+ import { parseMediaUrl, resolveShortUrl, isShortUrl } from "./platforms/url-parser";
5
+ import { resolveMedia } from "./platforms/resolvers";
6
+ import { buildInfoMessage, sendMediaResult } from "./utils/message";
7
+
8
+ const mediaSkills: AISkill[] = [
9
+ {
10
+ name: "media",
11
+ description:
12
+ "解析哔哩哔哩、抖音、小红书、快手的视频/图文链接,获取标题、作者、封面和视频地址",
13
+ permission: "member",
14
+ tools: [
15
+ {
16
+ name: "parse_media_url",
17
+ description:
18
+ "解析一个媒体链接,获取视频/图文的标题、作者、封面和视频地址。支持B站、抖音、小红书、快手平台。",
19
+ parameters: {
20
+ type: "object",
21
+ properties: {
22
+ url: {
23
+ type: "string",
24
+ description:
25
+ "需要解析的媒体链接,支持BV号、av号、B站/抖音/小红书/快手链接",
26
+ },
27
+ },
28
+ required: ["url"],
29
+ },
30
+ handler: async (args: any, runtimeCtx?: any) => {
31
+ const runtime = getMediaRuntimeState();
32
+ if (!runtime.amagiClient) {
33
+ return "媒体解析插件尚未初始化";
34
+ }
35
+
36
+ const url = String(args?.url || "").trim();
37
+ if (!url) {
38
+ return "缺少 url 参数";
39
+ }
40
+
41
+ const parsed = parseMediaUrl(url);
42
+ if (!parsed) {
43
+ return "无法识别该链接,请确认是否为B站/抖音/小红书/快手的有效链接";
44
+ }
45
+
46
+ try {
47
+ if (isShortUrl(parsed)) {
48
+ const resolvedUrl = await resolveShortUrl(parsed.id);
49
+ const reParsed = parseMediaUrl(resolvedUrl);
50
+ if (reParsed) {
51
+ Object.assign(parsed, reParsed);
52
+ }
53
+ }
54
+
55
+ const result = await resolveMedia(runtime.amagiClient, parsed);
56
+ const info = buildInfoMessage(parsed, result);
57
+
58
+ const ctx = runtimeCtx?.ctx;
59
+ const event = runtimeCtx?.event || runtimeCtx?.rawEvent;
60
+
61
+ if (ctx && event) {
62
+ try {
63
+ await sendMediaResult(ctx, event, parsed, result);
64
+ } catch {
65
+ // send failed, return info text
66
+ }
67
+ }
68
+
69
+ return `已解析媒体内容。以下是解析结果,知晓即可:\n${info}${result.videoUrl ? `\n视频地址: ${result.videoUrl}` : ""}`;
70
+ } catch (error) {
71
+ return `解析失败: ${error instanceof Error ? error.message : String(error)}`;
72
+ }
73
+ },
74
+ } as AITool,
75
+ ],
76
+ },
77
+ ];
78
+
79
+ export default mediaSkills;
package/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { MediaConfig } from "./config";
2
+
3
+ export type { MediaConfig };
4
+
5
+ export interface MediaStats {
6
+ likes?: number;
7
+ coins?: number;
8
+ favorites?: number;
9
+ shares?: number;
10
+ views?: number;
11
+ comments?: number;
12
+ danmaku?: number;
13
+ }
14
+
15
+ export interface ParsedMediaResult {
16
+ title: string;
17
+ author: string;
18
+ description: string;
19
+ coverUrl: string;
20
+ videoUrl: string;
21
+ duration?: number;
22
+ stats?: MediaStats;
23
+ }
24
+
25
+ export interface MediaRuntimeState {
26
+ config: MediaConfig;
27
+ amagiClient: any;
28
+ }
@@ -0,0 +1,67 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import type { MediaConfig } from "../types";
3
+
4
+ function normalizeErrorMessage(error: unknown): string {
5
+ if (error instanceof Error && error.message) {
6
+ return error.message;
7
+ }
8
+ if (typeof error === "string") {
9
+ return error;
10
+ }
11
+ try {
12
+ return JSON.stringify(error);
13
+ } catch {
14
+ return String(error);
15
+ }
16
+ }
17
+
18
+ export async function handleMediaError(options: {
19
+ ctx: MiokiContext;
20
+ event: any;
21
+ error: unknown;
22
+ platform: string;
23
+ config: MediaConfig;
24
+ }): Promise<void> {
25
+ const { ctx, event, error, platform, config } = options;
26
+ const errorMessage = normalizeErrorMessage(error);
27
+
28
+ ctx.logger.error(`[media] ${platform} 解析失败: ${errorMessage}`, error);
29
+
30
+ const cause = (error instanceof Error && (error as any).cause) ? (error as any).cause : null;
31
+ const ckExpired = cause?.error?.errorDescription?.includes("ck可能已经失效");
32
+
33
+ if (ckExpired) {
34
+ await event.reply(`${platform} Cookie 已失效,请联系管理员更新`, true);
35
+ return;
36
+ }
37
+
38
+ if (config.debug) {
39
+ await event.reply(`${platform} 解析失败: ${errorMessage}`, true);
40
+ return;
41
+ }
42
+
43
+ const aiService = ctx.services?.ai as any | undefined;
44
+ const chatRuntime = aiService?.getChatRuntime?.();
45
+
46
+ if (chatRuntime) {
47
+ try {
48
+ await chatRuntime.generateNotice({
49
+ event,
50
+ instruction: `媒体解析插件在解析${platform}内容时失败,错误信息: ${errorMessage}。请简短告知用户解析失败,并建议稍后重试或检查链接是否有效。`,
51
+ send: true,
52
+ promptInjections: [
53
+ {
54
+ title: "Media Plugin Notice",
55
+ content:
56
+ "A media link parsing action was triggered. Judge whether the user likely intended this action or triggered it accidentally. If it looks accidental or like a casual mention, weave a natural reply into the conversation without mentioning the plugin, tools, or commands. If the user seems to want this feature, respond helpfully. Keep the response concise.",
57
+ },
58
+ ],
59
+ });
60
+ return;
61
+ } catch (noticeError) {
62
+ ctx.logger.error(`[media] AI notice 发送失败: ${normalizeErrorMessage(noticeError)}`, noticeError);
63
+ }
64
+ }
65
+
66
+ await event.reply(`解析失败,请稍后重试或检查链接是否有效`, true);
67
+ }