mioku-plugin-media 1.0.0 → 2.1.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/README.md CHANGED
@@ -9,6 +9,15 @@
9
9
  - 返回封面图、标题、作者、简介和视频文件
10
10
  - 可选配置各平台 Cookies 以获取更高质量的视频
11
11
 
12
+ ## Cookies 配置
13
+
14
+ 配置 Cookies 可获取更高画质和完整解析能力。可使用内置扫码登录命令自动获取,或手动从浏览器复制。
15
+
16
+ ### 手动获取方式
17
+
18
+ 安装 Cookie-Editor 浏览器插件并在对应平台界面登录账号,点击 Cookie-Editor 右下方的导出,选择导出为字符串即可。
19
+
20
+
12
21
  ## 支持平台
13
22
 
14
23
  ### 哔哩哔哩
@@ -39,4 +48,4 @@
39
48
  },
40
49
  "debug": false
41
50
  }
42
- ```
51
+ ```
package/config.md CHANGED
@@ -15,7 +15,7 @@ fields:
15
15
  - key: base.cookies.kuaishou
16
16
  label: 快手 Cookie
17
17
  type: textarea
18
- description: 快手 Cookie,配置后可获取更完整的视频信息。留空则使用基础解析。
18
+ description: 快手 Cookie,配置后可正常解析作品。留空则无法解析。
19
19
 
20
20
  - key: base.cookies.xiaohongshu
21
21
  label: 小红书 Cookie
@@ -26,4 +26,9 @@ fields:
26
26
  label: 调试模式
27
27
  type: switch
28
28
  description: 开启后解析失败时直接发送错误信息,不走 AI 通知
29
+
30
+ - key: base.maxVideoDurationSeconds
31
+ label: 视频解析时长上限
32
+ type: number
33
+ description: 允许解析的视频最大时长(秒),默认 1200(20分钟)。超过限制的视频将被拒绝解析。
29
34
  ---
package/config.ts CHANGED
@@ -5,6 +5,7 @@ export interface MediaConfig {
5
5
  kuaishou: string;
6
6
  xiaohongshu: string;
7
7
  };
8
+ maxVideoDurationSeconds: number;
8
9
  debug: boolean;
9
10
  }
10
11
 
@@ -15,5 +16,6 @@ export const MEDIA_DEFAULTS: MediaConfig = {
15
16
  kuaishou: "",
16
17
  xiaohongshu: "",
17
18
  },
19
+ maxVideoDurationSeconds: 20 * 60,
18
20
  debug: false,
19
21
  };
package/index.ts CHANGED
@@ -1,11 +1,11 @@
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";
6
6
  import { extractMediaUrlFromEvent, resolveShortUrl, isShortUrl } from "./platforms/url-parser";
7
7
  import { resolveMedia } from "./platforms/resolvers";
8
- import { sendMediaResult } from "./utils/message";
8
+ import { sendMediaResult, sendDurationLimitResult } from "./utils/message";
9
9
  import { handleMediaError } from "./utils/error-handler";
10
10
  import { setMediaRuntimeState, resetMediaRuntimeState } from "./runtime";
11
11
 
@@ -102,6 +102,23 @@ export default definePlugin({
102
102
  }
103
103
 
104
104
  const result = await resolveMedia(amagiClient, parsed);
105
+
106
+ if (result.duration && config.maxVideoDurationSeconds > 0) {
107
+ if (result.duration > config.maxVideoDurationSeconds) {
108
+ ctx.logger.warn(
109
+ `[media] 视频时长超过限制: ${result.duration}秒 > ${config.maxVideoDurationSeconds}秒`,
110
+ );
111
+ await sendDurationLimitResult(
112
+ ctx,
113
+ event,
114
+ parsed,
115
+ result,
116
+ Math.floor(config.maxVideoDurationSeconds / 60),
117
+ );
118
+ return;
119
+ }
120
+ }
121
+
105
122
  await sendMediaResult(ctx, event, parsed, result);
106
123
  } catch (error) {
107
124
  await handleMediaError({
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "mioku-plugin-media",
3
- "version": "1.0.0",
3
+ "version": "2.1.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,23 @@ 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
+ douyin: {
31
+ fetcher: client.douyin.fetcher,
32
+ },
33
+ kuaishou: {
34
+ fetcher: client.kuaishou.fetcher,
35
+ },
36
+ xiaohongshu: {
37
+ fetcher: client.xiaohongshu.fetcher,
38
+ },
39
+ };
24
40
  }
@@ -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,76 @@
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
+ const KUAISHOU_SHORT_CODE_REGEX = /^([a-zA-Z0-9_-]+)$/;
6
+
7
+ async function resolveKuaishouShortCode(shortCode: string): Promise<string> {
8
+ const url = `https://v.kuaishou.com/${shortCode}`;
9
+ try {
10
+ const controller = new AbortController();
11
+ const timeout = setTimeout(() => controller.abort(), 10000);
12
+ const response = await fetch(url, {
13
+ redirect: "follow",
14
+ signal: controller.signal,
15
+ });
16
+ clearTimeout(timeout);
17
+ return response.url || url;
18
+ } catch {
19
+ return url;
20
+ }
21
+ }
22
+
23
+ function extractPhotoIdFromUrl(url: string): string | null {
24
+ const shortMatch = url.match(/\/short-video\/([a-zA-Z0-9_-]+)/);
25
+ if (shortMatch) return shortMatch[1];
26
+ const photoMatch = url.match(/\/photo\/([a-zA-Z0-9_-]+)/);
27
+ if (photoMatch) return photoMatch[1];
28
+ return null;
29
+ }
30
+
5
31
  export class KuaishouResolver implements PlatformResolver {
6
- async resolve(client: any, parsed: ParsedMediaUrl): Promise<ParsedMediaResult> {
7
- const photoId = parsed.id;
32
+ async resolve(
33
+ client: AmagiClient,
34
+ parsed: ParsedMediaUrl,
35
+ ): Promise<ParsedMediaResult> {
36
+ let photoId = parsed.id;
8
37
 
38
+ // If id is a short code (just the code like "KfhlEcGV"), resolve it first
39
+ if (
40
+ !parsed.id.startsWith("http") &&
41
+ KUAISHOU_SHORT_CODE_REGEX.test(parsed.id)
42
+ ) {
43
+ const resolvedUrl = await resolveKuaishouShortCode(parsed.id);
44
+ const extracted = extractPhotoIdFromUrl(resolvedUrl);
45
+ if (extracted) {
46
+ photoId = extracted;
47
+ }
48
+ } else if (parsed.id.startsWith("http")) {
49
+ // If id is a full URL, extract photoId from URL path
50
+ const extracted = extractPhotoIdFromUrl(parsed.id);
51
+ if (extracted) {
52
+ photoId = extracted;
53
+ }
54
+ }
9
55
  const result = await client.kuaishou.fetcher.fetchVideoWork({ photoId });
56
+
10
57
  if (!result.success) {
58
+ const errorInfo = (result.error as any) || {};
59
+ const amagiError = errorInfo.amagiError as any;
60
+ const errorDesc =
61
+ amagiError?.errorDescription || errorInfo.errorDescription || "";
62
+ const errorCode = result.code as any;
63
+
64
+ if (
65
+ errorDesc.includes("ck可能已经失效") ||
66
+ errorDesc.includes("接口返回内容为空") ||
67
+ errorCode === "INVALID_COOKIE" ||
68
+ errorCode === "UNKNOWN_ERROR"
69
+ ) {
70
+ throw new Error(
71
+ "快手接口返回内容为空,可能是作品不存在、链接有误或需要登录。请确认链接是否正确,或检查作品是否公开。",
72
+ );
73
+ }
11
74
  throw new Error(`快手作品解析失败: ${result.message}`);
12
75
  }
13
76
 
@@ -32,20 +95,39 @@ export class KuaishouResolver implements PlatformResolver {
32
95
  videoUrl = photo.photoUrl;
33
96
  } else if (photo.croppedPhotoUrl) {
34
97
  videoUrl = photo.croppedPhotoUrl;
35
- } else if (photo.videoResource?.h264?.adaptationSet?.[0]?.representation?.[0]?.url) {
36
- videoUrl = photo.videoResource.h264.adaptationSet[0].representation[0].url;
37
- } else if (photo.videoResource?.hevc?.adaptationSet?.[0]?.representation?.[0]?.url) {
38
- videoUrl = photo.videoResource.hevc.adaptationSet[0].representation[0].url;
98
+ } else if (
99
+ photo.videoResource?.h264?.adaptationSet?.[0]?.representation?.[0]?.url
100
+ ) {
101
+ videoUrl =
102
+ photo.videoResource.h264.adaptationSet[0].representation[0].url;
103
+ } else if (
104
+ photo.videoResource?.hevc?.adaptationSet?.[0]?.representation?.[0]?.url
105
+ ) {
106
+ videoUrl =
107
+ photo.videoResource.hevc.adaptationSet[0].representation[0].url;
39
108
  } else if (photo.manifest?.adaptationSet?.[0]?.representation?.[0]?.url) {
40
109
  videoUrl = photo.manifest.adaptationSet[0].representation[0].url;
41
110
  }
42
111
 
112
+ const images: string[] = [];
113
+ if (!videoUrl && photo.images && Array.isArray(photo.images)) {
114
+ for (const img of photo.images) {
115
+ const url =
116
+ img?.url ||
117
+ img?.url_list?.[0] ||
118
+ img?.download_addr?.url_list?.[0] ||
119
+ "";
120
+ if (url) images.push(url);
121
+ }
122
+ }
123
+
43
124
  return {
44
125
  title,
45
126
  author,
46
127
  description,
47
128
  coverUrl,
48
129
  videoUrl,
130
+ images: images.length > 0 ? images : undefined,
49
131
  duration,
50
132
  stats: {
51
133
  likes: photo.likeCount || photo.likeCnt,
@@ -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
+ }