mioku-plugin-media 1.0.0 → 2.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.
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { definePlugin, type MiokiContext } from "mioki";
2
- import type { ConfigService } from "../../src/services/config/tpyes";
2
+ import type { ConfigService } from "mioku";
3
3
  import type { MediaConfig } from "./types";
4
4
  import { MEDIA_DEFAULTS } from "./config";
5
5
  import { createMediaAmagiClient } from "./platforms/amagi-client";
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "mioku-plugin-media",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "流媒体解析插件,支持哔哩哔哩、抖音、小红书和快手平台",
5
5
  "main": "index.ts",
6
+ "type": "module",
6
7
  "keywords": [
7
8
  "mioku"
8
9
  ],
@@ -28,6 +29,14 @@
28
29
  "role": "member"
29
30
  }
30
31
  ]
32
+ },
33
+ "peerDependencies": {
34
+ "mioku": "^0.8.0",
35
+ "mioki": "^0.16.0"
36
+ },
37
+ "devDependencies": {
38
+ "mioku": "workspace:*",
39
+ "mioki": "^0.16.0"
31
40
  }
32
41
  }
33
42
  }
@@ -1,7 +1,8 @@
1
1
  import { createAmagiClient } from "@ikenxuan/amagi";
2
2
  import type { MediaConfig } from "../types";
3
+ import type { AmagiClient } from "./types";
3
4
 
4
- export function createMediaAmagiClient(config: MediaConfig): any {
5
+ export function createMediaAmagiClient(config: MediaConfig): AmagiClient {
5
6
  const cookies: Record<string, string> = {};
6
7
 
7
8
  if (config.cookies.bilibili?.trim()) {
@@ -17,8 +18,17 @@ export function createMediaAmagiClient(config: MediaConfig): any {
17
18
  cookies.xiaohongshu = config.cookies.xiaohongshu.trim();
18
19
  }
19
20
 
20
- return createAmagiClient({
21
+ const client = createAmagiClient({
21
22
  cookies,
22
23
  request: { timeout: 15000 },
23
24
  });
25
+
26
+ return {
27
+ bilibili: {
28
+ fetcher: client.bilibili.fetcher,
29
+ },
30
+ kuaishou: {
31
+ fetcher: client.kuaishou.fetcher,
32
+ },
33
+ };
24
34
  }
@@ -3,23 +3,41 @@ import type { ParsedMediaUrl } from "../types";
3
3
  import type { PlatformResolver } from "./types";
4
4
 
5
5
  export class BilibiliResolver implements PlatformResolver {
6
+ private readonly BV_REGEX = /\b(BV[a-zA-Z0-9]{10,})\b/;
7
+ private readonly AV_REGEX = /\b(av\d+)\b/i;
8
+
6
9
  async resolve(client: any, parsed: ParsedMediaUrl): Promise<ParsedMediaResult> {
10
+ if (parsed.subtype === "live") {
11
+ return this.resolveLive(client, parsed);
12
+ }
13
+
14
+ // 如果 id 是短链接 URL,先尝试解析出 BV 号
7
15
  let bvid = parsed.id;
8
16
  let avid: number | undefined;
9
17
 
10
- if (bvid.startsWith("av")) {
11
- avid = parseInt(bvid.slice(2), 10);
12
- }
13
-
14
18
  if (bvid.startsWith("http")) {
15
- const infoResult = await client.bilibili.fetcher.fetchVideoInfo({ bvid: "" });
16
- if (!infoResult.success) {
17
- throw new Error(`B站视频信息获取失败: ${infoResult.message}`);
18
- }
19
- const data = infoResult.data?.data || infoResult.data;
20
- bvid = data?.bvid || "";
21
- avid = data?.aid;
22
- if (!bvid) {
19
+ // 尝试从 URL 中直接提取 BV
20
+ const bvMatch = bvid.match(this.BV_REGEX);
21
+ const avMatch = bvid.match(this.AV_REGEX);
22
+ if (bvMatch) {
23
+ bvid = bvMatch[1];
24
+ } else if (avMatch) {
25
+ // 如果是 AV 号,先转换成 BV 号
26
+ avid = parseInt(avMatch[1].replace("av", ""), 10);
27
+ try {
28
+ const infoResult = await client.bilibili.fetcher.fetchVideoInfo({ bvid: `av${avid}` });
29
+ if (infoResult.success) {
30
+ const data = infoResult.data?.data || infoResult.data;
31
+ bvid = data?.bvid || "";
32
+ avid = data?.aid;
33
+ }
34
+ } catch {
35
+ // ignore conversion errors
36
+ }
37
+ if (!bvid) {
38
+ throw new Error("B站短链接解析失败,无法获取BV号");
39
+ }
40
+ } else {
23
41
  throw new Error("B站短链接解析失败,无法获取BV号");
24
42
  }
25
43
  }
@@ -92,4 +110,75 @@ export class BilibiliResolver implements PlatformResolver {
92
110
  },
93
111
  };
94
112
  }
113
+
114
+ private async resolveLive(client: any, parsed: ParsedMediaUrl): Promise<ParsedMediaResult> {
115
+ const roomId = parsed.id;
116
+
117
+ try {
118
+ const [liveInfoResult, initInfoResult] = await Promise.all([
119
+ client.bilibili.fetcher.fetchLiveRoomInfo({
120
+ room_id: roomId,
121
+ }),
122
+ client.bilibili.fetcher.fetchLiveRoomInitInfo({
123
+ room_id: roomId,
124
+ }),
125
+ ]);
126
+
127
+ if (!liveInfoResult.success) {
128
+ throw new Error(`B站直播间信息获取失败: ${liveInfoResult.message}`);
129
+ }
130
+
131
+ const liveData = liveInfoResult.data?.data || liveInfoResult.data;
132
+ if (!liveData) {
133
+ throw new Error("B站直播间信息为空");
134
+ }
135
+
136
+ const initData = initInfoResult.data?.data || initInfoResult.data;
137
+ const anchorUid = initData?.uid || liveData.uid;
138
+
139
+ let author = "未知主播";
140
+ if (anchorUid) {
141
+ try {
142
+ const userCardResult = await client.bilibili.fetcher.fetchUserCard({
143
+ host_mid: Number(anchorUid),
144
+ });
145
+ if (userCardResult.success) {
146
+ const cardData = userCardResult.data?.data || userCardResult.data;
147
+ author = cardData?.card?.uname || cardData?.uname || author;
148
+ }
149
+ } catch {
150
+ // ignore user card fetch errors
151
+ }
152
+ }
153
+
154
+ const title = liveData.title || "未知标题";
155
+ const description = liveData.description || "";
156
+ const coverUrl = liveData.user_cover || liveData.cover || "";
157
+ const liveStatus = liveData.live_status;
158
+ const isLive = liveStatus === 1;
159
+ const online = liveData.online || 0;
160
+ const attention = liveData.attention || 0;
161
+
162
+ return {
163
+ title,
164
+ author,
165
+ description,
166
+ coverUrl,
167
+ videoUrl: isLive ? `https://live.bilibili.com/${roomId}` : "",
168
+ duration: 0,
169
+ stats: {
170
+ likes: 0,
171
+ coins: 0,
172
+ favorites: 0,
173
+ shares: 0,
174
+ views: online,
175
+ comments: attention,
176
+ danmaku: 0,
177
+ },
178
+ liveStatus: isLive ? "直播中" : "未开播",
179
+ };
180
+ } catch (error) {
181
+ throw new Error(`B站直播间解析失败: ${error instanceof Error ? error.message : String(error)}`);
182
+ }
183
+ }
95
184
  }
@@ -19,6 +19,28 @@ export class DouyinResolver implements PlatformResolver {
19
19
  }
20
20
 
21
21
  const detail = data.aweme_detail || data;
22
+
23
+ // 判断作品类型:0=视频, 68=图集, 163=文章
24
+ const awemeType = detail.aweme_type;
25
+ const isArticle = awemeType === 163;
26
+ const isVideo = awemeType === 0 || awemeType === 55;
27
+ const isSlides = detail.is_slides === true;
28
+
29
+ // 文章类型
30
+ if (isArticle) {
31
+ return this.resolveArticleWork(detail);
32
+ }
33
+
34
+ // 图集/合辑类型(包含 images 数组)
35
+ if (!isVideo && !isArticle && detail.images) {
36
+ return this.resolveImageSet(detail, isSlides);
37
+ }
38
+
39
+ // 视频类型
40
+ return this.resolveVideoWork(detail);
41
+ }
42
+
43
+ private resolveVideoWork(detail: any): ParsedMediaResult {
22
44
  const title = detail.desc || detail.item_title || "未知标题";
23
45
  const author = detail.author?.nickname || "未知作者";
24
46
  const description = detail.desc || "";
@@ -48,26 +70,133 @@ export class DouyinResolver implements PlatformResolver {
48
70
  }
49
71
  }
50
72
 
73
+ const statistics = detail.statistics || detail.stats || {};
74
+
75
+ return {
76
+ title,
77
+ author,
78
+ description,
79
+ coverUrl,
80
+ videoUrl,
81
+ duration,
82
+ stats: {
83
+ likes: statistics.digg_count,
84
+ favorites: statistics.collect_count,
85
+ shares: statistics.share_count,
86
+ comments: statistics.comment_count,
87
+ views: statistics.play_count,
88
+ },
89
+ };
90
+ }
91
+
92
+ private resolveArticleWork(detail: any): ParsedMediaResult {
93
+ const articleInfo = detail.article_info || {};
94
+ const title = articleInfo.article_title || "未知标题";
95
+ const author = detail.author?.nickname || "未知作者";
96
+ const description = articleInfo.article_content || "";
97
+
98
+ // 文章类型的封面图
99
+ const coverUrl =
100
+ detail.video?.origin_cover?.url_list?.[0] ||
101
+ detail.video?.cover?.url_list?.[0] ||
102
+ "";
103
+
104
+ const statistics = detail.statistics || detail.stats || {};
105
+
106
+ return {
107
+ title,
108
+ author,
109
+ description,
110
+ coverUrl,
111
+ videoUrl: "",
112
+ stats: {
113
+ likes: statistics.digg_count,
114
+ favorites: statistics.collect_count,
115
+ shares: statistics.share_count,
116
+ comments: statistics.comment_count,
117
+ views: statistics.play_count,
118
+ },
119
+ };
120
+ }
121
+
122
+ private resolveImageSet(detail: any, isSlides: boolean): ParsedMediaResult {
123
+ const title = detail.preview_title || detail.desc || "未知标题";
124
+ const author = detail.author?.nickname || "未知作者";
125
+ const description = detail.desc || "";
126
+ const statistics = detail.statistics || detail.stats || {};
127
+
128
+ // 处理图片列表 - 收集所有静态图片URL
51
129
  const images: string[] = [];
52
130
  if (detail.images && Array.isArray(detail.images)) {
53
131
  for (const img of detail.images) {
54
- const url =
55
- img?.url_list?.[0] ||
56
- img?.download_addr?.url_list?.[0] ||
57
- "";
58
- if (url) images.push(url);
132
+ // clip_type: 2=静态图片, 4=短视频, 5=实况图
133
+ // 静态图片优先使用 url_list[0] 或 url_list[1]
134
+ if (img.clip_type === 2 || img.clip_type === undefined) {
135
+ const url =
136
+ img.url_list?.[0] ||
137
+ img.url_list?.[1] ||
138
+ img.download_addr?.url_list?.[0] ||
139
+ "";
140
+ if (url) images.push(url);
141
+ }
59
142
  }
60
143
  }
61
144
 
62
- const statistics = detail.statistics || detail.stats || {};
145
+ // 获取封面(第一张图或视频封面)
146
+ const coverUrl =
147
+ detail.images?.[0]?.url_list?.[0] ||
148
+ detail.images?.[0]?.url_list?.[1] ||
149
+ detail.video?.cover?.url_list?.[0] ||
150
+ "";
151
+
152
+ // 判断是否有实况图(clip_type !== 2)
153
+ const hasLivePhoto = detail.images?.some(
154
+ (img: any) => (img.clip_type ?? 2) !== 2
155
+ );
156
+
157
+ // 对于图集/合辑,可能有背景音乐
158
+ let musicUrl = "";
159
+ if (detail.music) {
160
+ if (detail.music.play_url?.uri) {
161
+ musicUrl = detail.music.play_url.uri;
162
+ } else if (detail.music.extra) {
163
+ try {
164
+ const extraData = JSON.parse(detail.music.extra);
165
+ if (extraData.original_song_url) {
166
+ musicUrl = extraData.original_song_url;
167
+ }
168
+ } catch {
169
+ // ignore parse error
170
+ }
171
+ }
172
+ }
173
+
174
+ // 收集视频URL(用于短视频或实况图)
175
+ const videoUrls: string[] = [];
176
+ if (detail.images && Array.isArray(detail.images)) {
177
+ for (const img of detail.images) {
178
+ // 短视频或实况图视频
179
+ if (img.clip_type === 4 || img.clip_type === 5) {
180
+ if (img.video?.play_addr_h264?.uri) {
181
+ videoUrls.push(
182
+ `https://aweme.snssdk.com/aweme/v1/play/?video_id=${img.video.play_addr_h264.uri}&ratio=1080p&line=0`
183
+ );
184
+ }
185
+ }
186
+ }
187
+ }
63
188
 
64
189
  return {
65
190
  title,
66
191
  author,
67
192
  description,
68
193
  coverUrl,
69
- videoUrl,
70
- duration,
194
+ videoUrl: "",
195
+ images,
196
+ videoUrls,
197
+ musicUrl,
198
+ hasLivePhoto,
199
+ isSlides,
71
200
  stats: {
72
201
  likes: statistics.digg_count,
73
202
  favorites: statistics.collect_count,
@@ -1,4 +1,4 @@
1
- import type { ParsedMediaUrl } from "../types";
1
+ import type { AmagiClient, ParsedMediaUrl } from "../types";
2
2
  import type { ParsedMediaResult } from "../../types";
3
3
  import type { PlatformResolver } from "./types";
4
4
  import { BilibiliResolver } from "./bilibili";
@@ -14,7 +14,7 @@ const resolvers: Record<string, PlatformResolver> = {
14
14
  };
15
15
 
16
16
  export async function resolveMedia(
17
- client: any,
17
+ client: AmagiClient,
18
18
  parsed: ParsedMediaUrl,
19
19
  ): Promise<ParsedMediaResult> {
20
20
  const resolver = resolvers[parsed.platform];
@@ -1,13 +1,19 @@
1
1
  import type { ParsedMediaResult } from "../../types";
2
- import type { ParsedMediaUrl } from "../types";
2
+ import type { AmagiClient, ParsedMediaUrl } from "../types";
3
3
  import type { PlatformResolver } from "./types";
4
4
 
5
5
  export class KuaishouResolver implements PlatformResolver {
6
- async resolve(client: any, parsed: ParsedMediaUrl): Promise<ParsedMediaResult> {
6
+ async resolve(client: AmagiClient, parsed: ParsedMediaUrl): Promise<ParsedMediaResult> {
7
7
  const photoId = parsed.id;
8
8
 
9
9
  const result = await client.kuaishou.fetcher.fetchVideoWork({ photoId });
10
10
  if (!result.success) {
11
+ const isCookieError = result.code === 401 ||
12
+ (result.data as any)?.errorDescription?.includes("ck可能已经失效") ||
13
+ (result.data as any)?.errorDescription?.includes("接口返回内容为空");
14
+ if (isCookieError) {
15
+ throw new Error("快手Cookie无效或已过期,请检查配置");
16
+ }
11
17
  throw new Error(`快手作品解析失败: ${result.message}`);
12
18
  }
13
19
 
@@ -40,12 +46,21 @@ export class KuaishouResolver implements PlatformResolver {
40
46
  videoUrl = photo.manifest.adaptationSet[0].representation[0].url;
41
47
  }
42
48
 
49
+ const images: string[] = [];
50
+ if (!videoUrl && photo.images && Array.isArray(photo.images)) {
51
+ for (const img of photo.images) {
52
+ const url = img?.url || img?.url_list?.[0] || img?.download_addr?.url_list?.[0] || "";
53
+ if (url) images.push(url);
54
+ }
55
+ }
56
+
43
57
  return {
44
58
  title,
45
59
  author,
46
60
  description,
47
61
  coverUrl,
48
62
  videoUrl,
63
+ images: images.length > 0 ? images : undefined,
49
64
  duration,
50
65
  stats: {
51
66
  likes: photo.likeCount || photo.likeCnt,
@@ -55,4 +70,4 @@ export class KuaishouResolver implements PlatformResolver {
55
70
  },
56
71
  };
57
72
  }
58
- }
73
+ }
@@ -39,12 +39,27 @@ export class XiaohongshuResolver implements PlatformResolver {
39
39
  noteData.image_list?.[0]?.url_pre ||
40
40
  "";
41
41
 
42
+ // 收集图片列表
43
+ const images: string[] = [];
44
+ if (noteData.image_list && Array.isArray(noteData.image_list)) {
45
+ for (const img of noteData.image_list) {
46
+ const url =
47
+ img?.url ||
48
+ img?.url_default ||
49
+ img?.url_pre ||
50
+ img?.download_addr?.url_list?.[0] ||
51
+ "";
52
+ if (url) images.push(url);
53
+ }
54
+ }
55
+
42
56
  return {
43
57
  title,
44
58
  author,
45
59
  description,
46
60
  coverUrl,
47
61
  videoUrl: "",
62
+ images: images.length > 0 ? images : undefined,
48
63
  stats: buildXhsStats(noteData.interact_info),
49
64
  };
50
65
  }
@@ -70,12 +85,52 @@ export class XiaohongshuResolver implements PlatformResolver {
70
85
  "";
71
86
  }
72
87
 
88
+ // 收集图片列表
89
+ const images: string[] = [];
90
+ if (noteCard.image_list && Array.isArray(noteCard.image_list)) {
91
+ for (const img of noteCard.image_list) {
92
+ const url =
93
+ img?.url ||
94
+ img?.url_default ||
95
+ img?.url_pre ||
96
+ img?.download_addr?.url_list?.[0] ||
97
+ "";
98
+ if (url) images.push(url);
99
+ }
100
+ }
101
+
102
+ // 判断是否有实况图(检查是否有 stream 数据)
103
+ const hasLivePhoto = noteCard.image_list?.some(
104
+ (img: any) => img.live_photo && img.stream
105
+ );
106
+
107
+ // 收集实况图视频流URL
108
+ const videoUrls: string[] = [];
109
+ if (noteCard.image_list && Array.isArray(noteCard.image_list)) {
110
+ for (const img of noteCard.image_list) {
111
+ if (img.stream) {
112
+ // 按优先级 h264 > h265 > av1 > h266
113
+ const streamData = img.stream;
114
+ if (streamData.h264?.length > 0) {
115
+ videoUrls.push(streamData.h264[0].master_url);
116
+ } else if (streamData.h265?.length > 0) {
117
+ videoUrls.push(streamData.h265[0].master_url);
118
+ } else if (streamData.av1?.length > 0) {
119
+ videoUrls.push(streamData.av1[0].master_url);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
73
125
  return {
74
126
  title,
75
127
  author,
76
128
  description,
77
129
  coverUrl,
78
130
  videoUrl,
131
+ images: images.length > 0 ? images : undefined,
132
+ videoUrls: videoUrls.length > 0 ? videoUrls : undefined,
133
+ hasLivePhoto,
79
134
  stats: buildXhsStats(noteCard.interact_info),
80
135
  };
81
136
  }
@@ -89,4 +144,4 @@ function buildXhsStats(interactInfo: any): import("../../types").MediaStats {
89
144
  comments: parseInt(interactInfo.comment_count, 10) || undefined,
90
145
  shares: parseInt(interactInfo.share_count, 10) || undefined,
91
146
  };
92
- }
147
+ }
@@ -3,6 +3,51 @@ export type Platform = "bilibili" | "douyin" | "kuaishou" | "xiaohongshu";
3
3
  export interface ParsedMediaUrl {
4
4
  platform: Platform;
5
5
  id: string;
6
- subtype?: "video" | "article" | "note" | "bangumi";
6
+ subtype?: "video" | "article" | "note" | "bangumi" | "live";
7
7
  extra?: Record<string, string>;
8
8
  }
9
+
10
+ export interface AmagiClient {
11
+ bilibili: {
12
+ fetcher: {
13
+ fetchVideoInfo(options: { bvid: string; typeMode?: "strict" | "loose" }): Promise<{
14
+ success: boolean;
15
+ data?: any;
16
+ message?: string;
17
+ }>;
18
+ fetchVideoStreamUrl(options: { avid: number; cid: number; typeMode?: "strict" | "loose" }): Promise<{
19
+ success: boolean;
20
+ data?: any;
21
+ message?: string;
22
+ }>;
23
+ fetchLiveRoomInfo(options: { room_id: string; typeMode?: "strict" | "loose" }): Promise<{
24
+ success: boolean;
25
+ data?: any;
26
+ message?: string;
27
+ }>;
28
+ fetchLiveRoomInitInfo(options: { room_id: string; typeMode?: "strict" | "loose" }): Promise<{
29
+ success: boolean;
30
+ data?: any;
31
+ message?: string;
32
+ }>;
33
+ fetchUserCard(options: { host_mid: number; typeMode?: "strict" | "loose" }): Promise<{
34
+ success: boolean;
35
+ data?: any;
36
+ message?: string;
37
+ }>;
38
+ };
39
+ };
40
+ kuaishou: {
41
+ fetcher: {
42
+ fetchVideoWork(options: {
43
+ photoId: string;
44
+ typeMode?: "strict" | "loose";
45
+ }): Promise<{
46
+ success: boolean;
47
+ code?: number;
48
+ data: any;
49
+ message?: string;
50
+ }>;
51
+ };
52
+ };
53
+ }
@@ -1,4 +1,4 @@
1
- import type { Platform, ParsedMediaUrl } from "./types";
1
+ import type { ParsedMediaUrl } from "./types";
2
2
 
3
3
  const BILIBILI_DOMAINS = [
4
4
  "bilibili.com",
@@ -20,7 +20,14 @@ const DOUYIN_DOMAINS = [
20
20
  "jingxuan.douyin.com",
21
21
  ];
22
22
 
23
- const KUAISHOU_DOMAINS = ["kuaishou.com", "www.kuaishou.com", "v.kuaishou.com"];
23
+ const KUAISHOU_DOMAINS = [
24
+ "kuaishou.com",
25
+ "www.kuaishou.com",
26
+ "v.kuaishou.com",
27
+ "v.m.chenzhongtech.com",
28
+ "m.chenzhongtech.com",
29
+ "chenzhongtech.com",
30
+ ];
24
31
 
25
32
  const XIAOHONGSHU_DOMAINS = [
26
33
  "xiaohongshu.com",
@@ -31,8 +38,10 @@ const XIAOHONGSHU_DOMAINS = [
31
38
  const BV_REGEX = /\b(BV[a-zA-Z0-9]{10,})\b/;
32
39
  const AV_REGEX = /\b(av(\d+))\b/i;
33
40
  const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi;
41
+ const BILIBILI_LIVE_REGEX = /\/(\d+)(?:\?|$)/;
34
42
  const DOUYIN_ID_REGEX = /\/video\/(\d+)/;
35
43
  const KUAISHOU_ID_REGEX = /\/short-video\/([a-zA-Z0-9_-]+)/;
44
+ const KUAISHOU_PHOTO_REGEX = /(?:\/fw)?\/photo\/([a-zA-Z0-9_-]+)/;
36
45
  const XHS_NOTE_REGEX = /\/explore\/([a-f0-9]{24})/;
37
46
  const XHS_DISCOVERY_REGEX = /\/discovery\/item\/([a-f0-9]{24})/;
38
47
  const XHSLINK_NOTE_REGEX = /\/([a-f0-9]{24})/;
@@ -133,6 +142,21 @@ function parseBilibiliUrl(url: string): ParsedMediaUrl | null {
133
142
  if (articleMatch) {
134
143
  return { platform: "bilibili", id: articleMatch[1], subtype: "article" };
135
144
  }
145
+
146
+ const liveMatch = url.match(/\/live\/(\d+)/);
147
+ if (liveMatch) {
148
+ return { platform: "bilibili", id: liveMatch[1], subtype: "live" };
149
+ }
150
+
151
+ if (
152
+ hostname === "live.bilibili.com" ||
153
+ hostname.endsWith(".live.bilibili.com")
154
+ ) {
155
+ const roomMatch = url.match(/\/(\d+)(?:\?|$)/);
156
+ if (roomMatch) {
157
+ return { platform: "bilibili", id: roomMatch[1], subtype: "live" };
158
+ }
159
+ }
136
160
  }
137
161
 
138
162
  return null;
@@ -174,11 +198,17 @@ function parseKuaishouUrl(url: string): ParsedMediaUrl | null {
174
198
  return { platform: "kuaishou", id: shortMatch[1], subtype: "video" };
175
199
  }
176
200
 
177
- const photoMatch = url.match(/photo\/(\d+)/);
201
+ const photoMatch = url.match(KUAISHOU_PHOTO_REGEX);
178
202
  if (photoMatch) {
179
203
  return { platform: "kuaishou", id: photoMatch[1], subtype: "video" };
180
204
  }
181
205
 
206
+ // Handle v.kuaishou.com/xxx short URLs like https://v.kuaishou.com/KfhlEcGV
207
+ const shortCodeMatch = url.match(/:\/\/[^/]+\/([a-zA-Z0-9_-]+)/);
208
+ if (shortCodeMatch) {
209
+ return { platform: "kuaishou", id: shortCodeMatch[1], subtype: "video" };
210
+ }
211
+
182
212
  return null;
183
213
  }
184
214
 
@@ -399,5 +429,8 @@ export function isShortUrl(parsed: ParsedMediaUrl): boolean {
399
429
  if (parsed.platform === "douyin" && parsed.id.startsWith("http")) {
400
430
  return true;
401
431
  }
432
+ if (parsed.platform === "kuaishou" && parsed.id.startsWith("http")) {
433
+ return true;
434
+ }
402
435
  return false;
403
436
  }
package/runtime.ts CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  getPluginRuntimeState,
4
4
  resetPluginRuntimeState,
5
5
  setPluginRuntimeState,
6
- } from "../../src";
6
+ } from "mioku";
7
7
 
8
8
  const PLUGIN_NAME = "media";
9
9
 
package/skills.ts CHANGED
@@ -1,7 +1,10 @@
1
- import type { AISkill, AITool } from "../../src";
2
- import type { SendNodeContentElement } from "napcat-sdk";
1
+ import type { AISkill, AITool } from "mioku";
3
2
  import { getMediaRuntimeState } from "./runtime";
4
- import { parseMediaUrl, resolveShortUrl, isShortUrl } from "./platforms/url-parser";
3
+ import {
4
+ parseMediaUrl,
5
+ resolveShortUrl,
6
+ isShortUrl,
7
+ } from "./platforms/url-parser";
5
8
  import { resolveMedia } from "./platforms/resolvers";
6
9
  import { buildInfoMessage, sendMediaResult } from "./utils/message";
7
10
 
package/types.ts CHANGED
@@ -20,9 +20,30 @@ export interface ParsedMediaResult {
20
20
  videoUrl: string;
21
21
  duration?: number;
22
22
  stats?: MediaStats;
23
+ liveStatus?: string;
24
+ /** 图集/合辑的图片列表 */
25
+ images?: string[];
26
+ /** 图集/合辑的短视频/实况图视频URL列表 */
27
+ videoUrls?: string[];
28
+ /** 图集/合辑的背景音乐URL */
29
+ musicUrl?: string;
30
+ /** 是否包含实况图 */
31
+ hasLivePhoto?: boolean;
32
+ /** 是否为合辑(图集中的多图组合) */
33
+ isSlides?: boolean;
23
34
  }
24
35
 
25
36
  export interface MediaRuntimeState {
26
37
  config: MediaConfig;
27
- amagiClient: any;
38
+ amagiClient: {
39
+ kuaishou: {
40
+ fetcher: {
41
+ fetchVideoWork(options: { photoId: string; typeMode?: "strict" | "loose" }): Promise<{
42
+ success: boolean;
43
+ data: any;
44
+ message?: string;
45
+ }>;
46
+ };
47
+ };
48
+ };
28
49
  }
@@ -22,46 +22,7 @@ export async function handleMediaError(options: {
22
22
  platform: string;
23
23
  config: MediaConfig;
24
24
  }): Promise<void> {
25
- const { ctx, event, error, platform, config } = options;
25
+ const { ctx, error, platform } = options;
26
26
  const errorMessage = normalizeErrorMessage(error);
27
-
28
27
  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
28
  }
package/utils/message.ts CHANGED
@@ -44,6 +44,10 @@ export function buildInfoMessage(parsed: ParsedMediaUrl, result: ParsedMediaResu
44
44
  lines.push(`【${platform}】${result.title}`);
45
45
  lines.push(`作者:${result.author}`);
46
46
 
47
+ if (result.liveStatus) {
48
+ lines.push(`状态:${result.liveStatus}`);
49
+ }
50
+
47
51
  if (result.duration && result.duration > 0) {
48
52
  lines.push(`时长:${formatDuration(result.duration)}`);
49
53
  }
@@ -53,19 +57,32 @@ export function buildInfoMessage(parsed: ParsedMediaUrl, result: ParsedMediaResu
53
57
  lines.push(`简介:${desc}`);
54
58
  }
55
59
 
60
+ // 对于图片内容,显示数量信息
61
+ if (result.images && result.images.length > 0) {
62
+ lines.push(`图片:${result.images.length}张`);
63
+ }
64
+ if (result.videoUrls && result.videoUrls.length > 0) {
65
+ lines.push(`视频:${result.videoUrls.length}个`);
66
+ }
67
+
56
68
  return lines.join("\n");
57
69
  }
58
70
 
59
- function buildSummaryText(platform: string, stats?: MediaStats): string {
71
+ function buildSummaryText(platform: string, subtype: string | undefined, stats?: MediaStats): string {
60
72
  if (!stats) return "";
61
73
 
62
74
  const parts: string[] = [];
63
75
 
64
76
  if (platform === "bilibili") {
65
- if (stats.likes != null) parts.push(`赞${formatCount(stats.likes)}`);
66
- if (stats.coins != null) parts.push(`币${formatCount(stats.coins)}`);
67
- if (stats.favorites != null) parts.push(`藏${formatCount(stats.favorites)}`);
68
- if (stats.shares != null) parts.push(`转${formatCount(stats.shares)}`);
77
+ if (subtype === "live") {
78
+ if (stats.views != null && stats.views > 0) parts.push(`在线${formatCount(stats.views)}`);
79
+ if (stats.comments != null && stats.comments > 0) parts.push(`关注${formatCount(stats.comments)}`);
80
+ } else {
81
+ if (stats.likes != null) parts.push(`赞${formatCount(stats.likes)}`);
82
+ if (stats.coins != null) parts.push(`币${formatCount(stats.coins)}`);
83
+ if (stats.favorites != null) parts.push(`藏${formatCount(stats.favorites)}`);
84
+ if (stats.shares != null) parts.push(`转${formatCount(stats.shares)}`);
85
+ }
69
86
  } else {
70
87
  if (stats.likes != null) parts.push(`赞${formatCount(stats.likes)}`);
71
88
  if (stats.favorites != null) parts.push(`藏${formatCount(stats.favorites)}`);
@@ -79,13 +96,29 @@ function buildSummaryText(platform: string, stats?: MediaStats): string {
79
96
  function buildForwardDisplay(parsed: ParsedMediaUrl, result: ParsedMediaResult): ForwardDisplayOptions {
80
97
  const displayTitle = PLATFORM_DISPLAY_TITLES[parsed.platform] || "媒体解析";
81
98
 
99
+ let summary = buildSummaryText(parsed.platform, parsed.subtype, result.stats);
100
+
101
+ if (result.liveStatus) {
102
+ summary = summary ? `${result.liveStatus} ${summary}` : result.liveStatus;
103
+ }
104
+
105
+ // 对于图片集/合辑,增加内容数量信息
106
+ if (result.images && result.images.length > 0) {
107
+ const imageInfo = `${result.images.length}张图片`;
108
+ summary = summary ? `${imageInfo} ${summary}` : imageInfo;
109
+ }
110
+ if (result.videoUrls && result.videoUrls.length > 0) {
111
+ const videoInfo = `${result.videoUrls.length}个视频`;
112
+ summary = summary ? `${videoInfo} ${summary}` : videoInfo;
113
+ }
114
+
82
115
  return {
83
116
  source: displayTitle,
84
117
  news: [
85
118
  { text: truncateText(result.title, 26) },
86
119
  { text: result.author },
87
120
  ],
88
- summary: buildSummaryText(parsed.platform, result.stats),
121
+ summary,
89
122
  };
90
123
  }
91
124
 
@@ -133,7 +166,34 @@ function buildForwardNodes(
133
166
  content: [ctx.segment.text(infoText)],
134
167
  } as SendNodeContentElement);
135
168
 
136
- if (result.videoUrl) {
169
+ // 处理图片集/合辑
170
+ if (result.images && result.images.length > 0) {
171
+ // 如果既有图片又有视频/实况图,发送纯图片
172
+ // 否则直接发送图片
173
+ for (const imageUrl of result.images) {
174
+ nodes.push({
175
+ type: "node",
176
+ user_id: userId,
177
+ nickname,
178
+ content: [ctx.segment.image(imageUrl)],
179
+ } as SendNodeContentElement);
180
+ }
181
+ }
182
+
183
+ // 处理短视频/实况图视频
184
+ if (result.videoUrls && result.videoUrls.length > 0) {
185
+ for (const videoUrl of result.videoUrls) {
186
+ nodes.push({
187
+ type: "node",
188
+ user_id: userId,
189
+ nickname,
190
+ content: [(ctx.segment as any).video(videoUrl)],
191
+ } as SendNodeContentElement);
192
+ }
193
+ }
194
+
195
+ // 处理常规视频(单个视频)
196
+ if (result.videoUrl && (!result.videoUrls || result.videoUrls.length === 0)) {
137
197
  nodes.push({
138
198
  type: "node",
139
199
  user_id: userId,
@@ -227,11 +287,21 @@ export async function sendMediaResult(
227
287
  } catch (primaryError) {
228
288
  try {
229
289
  if (event?.message_type === "group" && event?.group_id != null) {
230
- await bot.sendGroupMsg(event.group_id, nodes);
290
+ const textPayload = [ctx.segment.text(buildInfoMessage(parsed, result))];
291
+ if (result.coverUrl) {
292
+ await bot.sendGroupMsg(event.group_id, [ctx.segment.image(result.coverUrl), ...textPayload]);
293
+ } else {
294
+ await bot.sendGroupMsg(event.group_id, textPayload);
295
+ }
231
296
  return;
232
297
  }
233
298
  if (event?.user_id != null) {
234
- await bot.sendPrivateMsg(event.user_id, nodes);
299
+ const textPayload = [ctx.segment.text(buildInfoMessage(parsed, result))];
300
+ if (result.coverUrl) {
301
+ await bot.sendPrivateMsg(event.user_id, [ctx.segment.image(result.coverUrl), ...textPayload]);
302
+ } else {
303
+ await bot.sendPrivateMsg(event.user_id, textPayload);
304
+ }
235
305
  return;
236
306
  }
237
307
  } catch {
@@ -241,4 +311,4 @@ export async function sendMediaResult(
241
311
 
242
312
  const infoText = buildInfoMessage(parsed, result);
243
313
  await event.reply(infoText);
244
- }
314
+ }