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 +1 -1
- package/package.json +10 -1
- package/platforms/amagi-client.ts +12 -2
- package/platforms/resolvers/bilibili.ts +101 -12
- package/platforms/resolvers/douyin.ts +137 -8
- package/platforms/resolvers/index.ts +2 -2
- package/platforms/resolvers/kuaishou.ts +18 -3
- package/platforms/resolvers/xiaohongshu.ts +56 -1
- package/platforms/types.ts +46 -1
- package/platforms/url-parser.ts +36 -3
- package/runtime.ts +1 -1
- package/skills.ts +6 -3
- package/types.ts +22 -1
- package/utils/error-handler.ts +1 -40
- package/utils/message.ts +80 -10
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { definePlugin, type MiokiContext } from "mioki";
|
|
2
|
-
import type { ConfigService } from "
|
|
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": "
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/platforms/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/platforms/url-parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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 = [
|
|
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(
|
|
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
package/skills.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { AISkill, AITool } from "
|
|
2
|
-
import type { SendNodeContentElement } from "napcat-sdk";
|
|
1
|
+
import type { AISkill, AITool } from "mioku";
|
|
3
2
|
import { getMediaRuntimeState } from "./runtime";
|
|
4
|
-
import {
|
|
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:
|
|
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
|
}
|
package/utils/error-handler.ts
CHANGED
|
@@ -22,46 +22,7 @@ export async function handleMediaError(options: {
|
|
|
22
22
|
platform: string;
|
|
23
23
|
config: MediaConfig;
|
|
24
24
|
}): Promise<void> {
|
|
25
|
-
const { ctx,
|
|
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 (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|