@lmna22/aio-downloader 2.0.0 → 2.0.2

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
@@ -1,6 +1,6 @@
1
1
  # @lmna22/aio-downloader
2
2
 
3
- > All-in-one media downloader for YouTube, Instagram, TikTok, Pinterest, Pixiv, X/Twitter, and Lahelu.
3
+ > All-in-one media downloader for YouTube, Instagram, TikTok, Pinterest, Pixiv, X/Twitter, Lahelu, and Xiaohongshu/RedNote.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@lmna22/aio-downloader.svg)](https://www.npmjs.com/package/@lmna22/aio-downloader)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -21,6 +21,7 @@ Scrape and download videos, audio, and images from multiple platforms with a sin
21
21
  | **Pixiv** | — | — | ✅ (Original Resolution) | ✅ |
22
22
  | **X / Twitter** | ✅ (Best quality) | — | ✅ | — |
23
23
  | **Lahelu** | ✅ | — | ✅ | — |
24
+ | **Xiaohongshu/RedNote** | ✅ | — | ✅ | — |
24
25
 
25
26
  - 🔗 **Auto-detect platform** from URL — just pass any supported link
26
27
  - 📦 **Programmatic API** — designed for Node.js applications, bots, and scripts
@@ -53,11 +54,11 @@ npm install puppeteer
53
54
  ```javascript
54
55
  const { lmna, aioDownloader } = require("@lmna22/aio-downloader");
55
56
 
56
- // Using the lmna namespace (recommended)
57
+ // Recommended: use the lmna namespace
57
58
  const result = await lmna.youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 5);
58
59
  console.log(result);
59
60
 
60
- // Or auto-detect platform from URL
61
+ // Unified: auto-detect platform from URL
61
62
  const result2 = await aioDownloader("https://www.instagram.com/p/ABC123/");
62
63
  console.log(result2);
63
64
  ```
@@ -71,209 +72,53 @@ console.log(result2);
71
72
  ```javascript
72
73
  const { lmna } = require("@lmna22/aio-downloader");
73
74
 
74
- // Quality options:
75
- // 1 = 144p, 2 = 360p, 3 = 480p, 4 = 720p,
76
- // 5 = 1080p, 6 = 1440p, 7 = 2160p,
77
- // 8 = Audio only (MP3), 9 = Get bitrate list
78
-
79
- // Download a video in 1080p
75
+ // Quality: 1=144p, 2=360p, 5=1080p, 8=MP3, 9=bitrate list
80
76
  const result = await lmna.youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 5);
81
77
 
82
78
  if (result.status) {
83
- console.log(result.data.title); // "Rick Astley - Never Gonna Give You Up"
84
- console.log(result.data.channel); // "Rick Astley"
85
- console.log(result.data.views); // 1500000000
86
- console.log(result.data.size); // Buffer size in bytes
87
- console.log(result.data.type); // "mp4" or "mp3"
88
-
89
- // Save to file
79
+ console.log(result.data.title);
90
80
  const fs = require("fs");
91
81
  fs.writeFileSync(`${result.data.title}.${result.data.type}`, result.data.result);
92
82
  }
93
-
94
- // Download audio only
95
- const audio = await lmna.youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 8);
96
-
97
- // Get available audio bitrates
98
- const bitrates = await lmna.youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 9);
99
- console.log(bitrates.data.bitrateList);
100
-
101
- // Download entire playlist
102
- const playlist = await lmna.youtubePlaylist(
103
- "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf",
104
- 5, // quality
105
- "./my-playlist" // output folder
106
- );
107
83
  ```
108
84
 
109
85
  ### Instagram
110
86
 
111
87
  ```javascript
112
88
  const { lmna } = require("@lmna22/aio-downloader");
113
-
114
89
  const result = await lmna.instagram("https://www.instagram.com/p/ABC123/");
115
-
116
- if (result.status) {
117
- console.log(result.data.url); // Array of download URLs
118
- console.log(result.data.caption); // Post caption
119
- console.log(result.data.username); // "@username"
120
- console.log(result.data.like); // Like count
121
- console.log(result.data.comment); // Comment count
122
- console.log(result.data.isVideo); // true/false
123
-
124
- // Download all media
125
- const { download } = require("@lmna22/aio-downloader");
126
- for (let i = 0; i < result.data.url.length; i++) {
127
- await download(result.data.url[i], `./downloads/ig_${i + 1}.mp4`);
128
- }
129
- }
130
90
  ```
131
91
 
132
92
  ### TikTok
133
93
 
134
94
  ```javascript
135
95
  const { lmna } = require("@lmna22/aio-downloader");
136
-
137
96
  const result = await lmna.tiktok("https://www.tiktok.com/@user/video/1234567890");
138
-
139
- if (result.status) {
140
- const data = result.data;
141
-
142
- console.log(data.description); // Video description
143
- console.log(data.author.nickname); // Author name
144
- console.log(data.author.uniqueId); // @username
145
- console.log(data.stats.likes); // Like count
146
- console.log(data.stats.comments); // Comment count
147
- console.log(data.stats.plays); // Play count
148
- console.log(data.music.title); // Music title
149
- console.log(data.videoInfo.duration); // Duration in seconds
150
- console.log(data.videoInfo.width); // Video width
151
- console.log(data.videoInfo.height); // Video height
152
-
153
- // Video is returned as a Buffer — save directly
154
- if (data.videoBuffer) {
155
- const fs = require("fs");
156
- fs.writeFileSync("tiktok_video.mp4", data.videoBuffer);
157
- }
158
-
159
- // For photo/slide posts
160
- if (data.images) {
161
- console.log(data.images); // Array of image URLs
162
- }
163
- }
164
97
  ```
165
98
 
166
- ### Pinterest
99
+ ### Xiaohongshu / RedNote
167
100
 
168
101
  ```javascript
169
102
  const { lmna } = require("@lmna22/aio-downloader");
170
103
 
171
- // From a direct pin URL
172
- const data = await lmna.pinterest("https://www.pinterest.com/pin/123456789/");
173
-
174
- // Or search by keyword
175
- const searchResults = await lmna.pinterest("aesthetic wallpaper", { limit: 20 });
176
-
177
- console.log(data.results);
178
- // [
179
- // {
180
- // id: "123456789",
181
- // title: "Beautiful Wallpaper",
182
- // link: "https://www.pinterest.com/pin/123456789/",
183
- // image: "https://i.pinimg.com/originals/...",
184
- // source: "example.com"
185
- // }
186
- // ]
187
- ```
188
-
189
- ### Pixiv
190
-
191
- ```javascript
192
- const { lmna } = require("@lmna22/aio-downloader");
193
-
194
- // From an artwork URL
195
- const data = await lmna.pixiv("https://www.pixiv.net/artworks/12345678");
196
-
197
- // Or search by tag/keyword
198
- const searchResults = await lmna.pixiv("landscape", { limit: 5 });
199
-
200
- console.log(data.results);
201
- // [
202
- // {
203
- // id: "12345678",
204
- // title: "Beautiful Landscape",
205
- // link: "https://www.pixiv.net/artworks/12345678",
206
- // image: "https://i.pximg.net/img-original/...",
207
- // artist: "ArtistName",
208
- // artistUrl: "https://www.pixiv.net/users/999",
209
- // userId: "999",
210
- // images: ["https://i.pximg.net/img-original/..."]
211
- // }
212
- // ]
213
-
214
- // Skip enrichment for faster results (no original resolution images)
215
- const fast = await lmna.pixiv("landscape", { limit: 10, enrich: false });
216
- ```
217
-
218
- ### X / Twitter
219
-
220
- ```javascript
221
- const { lmna } = require("@lmna22/aio-downloader");
222
-
223
- const result = await lmna.twitter("https://x.com/user/status/1234567890");
104
+ const result = await lmna.xiaohongshu("https://www.xiaohongshu.com/explore/abc123");
224
105
 
225
106
  if (result.status) {
226
- console.log(result.data.author); // "username"
227
- console.log(result.data.description); // Tweet text
228
- console.log(result.data.like); // Like count
229
- console.log(result.data.view); // View count
230
- console.log(result.data.retweet); // Retweet count
231
- console.log(result.data.sensitiveContent); // true/false
232
-
233
- // result.data.result contains media items:
234
- // [
235
- // { type: "video", thumb: "https://...", url: "https://..." },
236
- // { type: "image", url: "https://...?format=png&name=large" },
237
- // { type: "gif", thumb: "https://...", url: "https://..." }
238
- // ]
239
-
240
- // Download all media
241
- const { download } = require("@lmna22/aio-downloader");
242
- for (let i = 0; i < result.data.result.length; i++) {
243
- const media = result.data.result[i];
244
- const ext = media.type === "image" ? ".png" : ".mp4";
245
- await download(media.url, `./downloads/tweet_${i + 1}${ext}`);
246
- }
247
- }
248
- ```
249
-
250
- ### Lahelu
251
-
252
- ```javascript
253
- const { lmna } = require("@lmna22/aio-downloader");
254
-
255
- const result = await lmna.lahelu("https://lahelu.com/post/abc123");
256
-
257
- if (result.status) {
258
- const data = result.data;
259
-
260
- console.log(data.title); // Post title
261
- console.log(data.author); // Username
262
- console.log(data.createdAt); // Creation date
263
- console.log(data.stats.views); // View count
264
- console.log(data.stats.likes); // Like count
265
- console.log(data.stats.comments); // Comment count
266
- console.log(data.media.type); // "video" or "image"
267
- console.log(data.media.format); // File format (mp4, jpg, etc.)
268
- console.log(data.media.url); // Direct download URL
269
- console.log(data.fileName); // Suggested filename
270
-
107
+ console.log(result.data.title);
108
+ console.log(result.data.author.nickname);
109
+ console.log(result.data.stats.likes);
110
+ console.log(result.data.stats.collects);
111
+ console.log(result.data.stats.comments);
112
+ console.log(result.data.media.url);
113
+
271
114
  // Download the media
272
115
  const { download } = require("@lmna22/aio-downloader");
273
- await download(data.media.url, `./downloads/${data.fileName}`);
116
+ await download(result.data.media.url, `./downloads/${result.data.fileName}`);
274
117
  }
275
118
  ```
276
119
 
120
+ *(See the API Reference below for other platforms: Pinterest, Pixiv, Twitter, Lahelu)*
121
+
277
122
  ---
278
123
 
279
124
  ## 📥 Download Helper
@@ -309,6 +154,9 @@ await aioDownloader("https://www.pinterest.com/pin/456/");
309
154
  await aioDownloader("https://www.pixiv.net/artworks/789");
310
155
  await aioDownloader("https://x.com/user/status/101112");
311
156
  await aioDownloader("https://lahelu.com/post/abc123");
157
+ await aioDownloader("https://www.xiaohongshu.com/explore/abc123");
158
+ await aioDownloader("https://www.rednote.com/explore/abc123");
159
+ await aioDownloader("https://xhslink.com/abc123");
312
160
 
313
161
  // Force a specific platform
314
162
  await aioDownloader("https://example.com/video", { platform: "youtube", quality: 5 });
@@ -322,6 +170,9 @@ detectPlatform("https://pin.it/abc123"); // "pinterest"
322
170
  detectPlatform("https://pixiv.net/artworks/456"); // "pixiv"
323
171
  detectPlatform("https://x.com/user/status/789"); // "twitter"
324
172
  detectPlatform("https://lahelu.com/post/abc"); // "lahelu"
173
+ detectPlatform("https://xiaohongshu.com/explore/abc"); // "xiaohongshu"
174
+ detectPlatform("https://rednote.com/explore/abc"); // "xiaohongshu"
175
+ detectPlatform("https://xhslink.com/abc"); // "xiaohongshu"
325
176
  detectPlatform("https://unknown.com"); // null
326
177
  ```
327
178
 
@@ -329,7 +180,9 @@ detectPlatform("https://unknown.com"); // null
329
180
 
330
181
  ## 📚 API Reference
331
182
 
332
- ### `lmna` Namespace (Recommended)
183
+ ### `lmna` Namespace (Mandatory for individual scrapers)
184
+
185
+ Instead of individual function exports, all scrapers are now grouped under the `lmna` object:
333
186
 
334
187
  ```javascript
335
188
  const { lmna } = require("@lmna22/aio-downloader");
@@ -338,10 +191,13 @@ await lmna.youtube(url, quality);
338
191
  await lmna.youtubePlaylist(url, quality, folderPath);
339
192
  await lmna.instagram(url);
340
193
  await lmna.tiktok(url);
341
- await lmna.pinterest(input, options);
342
- await lmna.pixiv(input, options);
194
+ await lmna.pinterest(url, options);
195
+ await lmna.pixiv(url, options);
343
196
  await lmna.twitter(url);
344
197
  await lmna.lahelu(url);
198
+ await lmna.xiaohongshu(url);
199
+
200
+ // All return: { status, platform, data?, message? }
345
201
  ```
346
202
 
347
203
  ---
@@ -358,137 +214,29 @@ Auto-detect platform and scrape media data.
358
214
 
359
215
  ---
360
216
 
361
- ### `lmna.youtube(url, quality)`
362
-
363
- | Parameter | Type | Description |
364
- |---|---|---|
365
- | `url` | `string` | YouTube video URL |
366
- | `quality` | `number` | 1=144p, 2=360p, 3=480p, 4=720p, 5=1080p, 6=1440p, 7=2160p, 8=MP3, 9=bitrate list |
367
-
368
- **Returns:** `{ status, platform, data: { title, result (Buffer), size, quality, desc, views, likes, channel, uploadDate, thumb, type } }`
369
-
370
- ---
371
-
372
- ### `lmna.youtubePlaylist(url, quality, folderPath?)`
373
-
374
- Downloads all videos from a YouTube playlist.
375
-
376
- **Returns:** `{ status, platform, data: { title, resultPath[], metadata[] } }`
377
-
378
- ---
379
-
380
- ### `lmna.instagram(url)`
381
-
382
- Uses 4 fallback methods for maximum reliability.
383
-
384
- **Returns:** `{ status, platform, data: { url[], caption, username, like, comment, isVideo } }`
385
-
386
- ---
387
-
388
- ### `lmna.tiktok(url)`
389
-
390
- Returns video as a Buffer (no watermark). Uses direct scraping with tikwm.com API fallback.
391
-
392
- **Returns:** `{ status, platform, data: { videoId, description, videoUrl, videoBuffer (Buffer), videoInfo, author, music, stats, locationCreated, images? } }`
393
-
394
- ---
395
-
396
- ### `lmna.pinterest(input, options?)`
397
-
398
- | Parameter | Type | Description |
399
- |---|---|---|
400
- | `input` | `string` | Pin URL or search keyword |
401
- | `options.limit` | `number` | Max results for search (default: 10) |
402
-
403
- **Returns:** `{ status, platform, method, total, results: [{ id, title, link, image, source }] }`
404
-
405
- ---
406
-
407
- ### `lmna.pixiv(input, options?)`
408
-
409
- | Parameter | Type | Description |
410
- |---|---|---|
411
- | `input` | `string` | Artwork URL or search keyword |
412
- | `options.limit` | `number` | Max results (default: 10) |
413
- | `options.enrich` | `boolean` | Fetch original resolution images (default: true) |
414
-
415
- **Returns:** `{ status, platform, method, total, results: [{ id, title, link, image, artist, artistUrl, userId, images[] }] }`
416
-
417
- ---
418
-
419
- ### `lmna.twitter(url)`
420
-
421
- Extracts best quality video/image/gif from tweets via Twitter GraphQL API.
422
-
423
- **Returns:** `{ status, platform, data: { author, like, view, retweet, description, sensitiveContent, result: [{ type, url, thumb? }] } }`
424
-
425
- ---
426
-
427
- ### `lmna.lahelu(url)`
428
-
429
- Scrapes video or image from Lahelu posts.
430
-
431
- **Returns:** `{ status, platform, data: { postId, title, author, createdAt, stats: { views, likes, comments }, media: { type, format, url, desc }, fileName } }`
432
-
433
- ---
434
-
435
- ### `download(url, outputPath, options?)`
436
-
437
- Helper to download any file to disk.
438
-
439
- | Parameter | Type | Description |
440
- |---|---|---|
441
- | `url` | `string` | Direct download URL |
442
- | `outputPath` | `string` | Local file path |
443
- | `options.headers` | `object` | Custom request headers |
444
- | `options.timeout` | `number` | Timeout in ms (default: 120000) |
445
- | `options.onProgress` | `function` | `({ downloaded, total, percentage })` |
446
-
447
- **Returns:** `{ path, size, filename }`
448
-
449
- ---
450
-
451
- ### `detectPlatform(url)`
452
-
453
- **Returns:** `"youtube"` | `"instagram"` | `"tiktok"` | `"pinterest"` | `"pixiv"` | `"twitter"` | `"lahelu"` | `null`
454
-
455
- ---
456
-
457
- ## 🔄 Migration from v1.0.x
458
-
459
- If you're upgrading from the old API, the legacy named exports still work:
217
+ ### Utility Exports
460
218
 
461
219
  ```javascript
462
- // Old way (still works)
463
- const { tiktokDownloader } = require("@lmna22/aio-downloader");
464
- await tiktokDownloader(url);
465
-
466
- // New way (recommended)
467
- const { lmna } = require("@lmna22/aio-downloader");
468
- await lmna.tiktok(url);
220
+ const { detectPlatform, download } = require("@lmna22/aio-downloader");
469
221
  ```
470
222
 
223
+ | Function | Description |
224
+ |---|---|
225
+ | `detectPlatform(url)` | Returns the platform name from URL |
226
+ | `download(url, path, options)` | Helper to download files to disk |
227
+
471
228
  ---
472
229
 
473
230
  ## ⚠️ Error Handling
474
231
 
475
- All functions return `{ status: false, platform: "...", message: "..." }` on failure:
476
-
477
- ```javascript
478
- const { lmna } = require("@lmna22/aio-downloader");
479
-
480
- const result = await lmna.youtube("https://www.youtube.com/watch?v=invalid", 5);
481
- if (!result.status) {
482
- console.error("Failed:", result.message);
483
- }
484
- ```
232
+ All functions return `{ status: false, platform: "...", message: "..." }` on failure.
485
233
 
486
234
  ---
487
235
 
488
236
  ## 📋 Requirements
489
237
 
490
238
  - **Node.js** >= 14.0.0
491
- - **puppeteer** (optional) — fallback for Pinterest/Pixiv when axios scraping is blocked
239
+ - **puppeteer** (optional) — fallback for Pinterest/Pixiv
492
240
 
493
241
  ---
494
242
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lmna22/aio-downloader",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "All-in-one media downloader for YouTube, Instagram, TikTok, Pinterest, Pixiv, and X/Twitter. Scrape and download videos, audio, and images from multiple platforms with a single library.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -37,17 +37,10 @@
37
37
  "axios-cookiejar-support": "^5.0.0",
38
38
  "cheerio": "^1.0.0",
39
39
  "ffmpeg-static": "^5.0.0",
40
+ "puppeteer": "^24.0.0",
40
41
  "qs": "^6.13.0",
41
42
  "tough-cookie": "^5.1.0",
42
- "youtube-dl-exec": "^3.0.0"
43
- },
44
- "peerDependencies": {
45
- "puppeteer": ">=20.0.0"
46
- },
47
- "peerDependenciesMeta": {
48
- "puppeteer": {
49
- "optional": true
50
- }
43
+ "youtube-dl-exec": "^3.1.4"
51
44
  },
52
45
  "engines": {
53
46
  "node": ">=14.0.0"
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ const pinterestDownloader = require("./lib/pinterest");
5
5
  const pixivDownloader = require("./lib/pixiv");
6
6
  const twitterDownloader = require("./lib/twitter");
7
7
  const laheluDownloader = require("./lib/lahelu");
8
+ const xiaohongshuDownloader = require("./lib/xiaohongshu");
8
9
  const download = require("./download");
9
10
 
10
11
  const PLATFORM_PATTERNS = [
@@ -15,6 +16,7 @@ const PLATFORM_PATTERNS = [
15
16
  { name: "pixiv", test: (url) => /pixiv\.net\//i.test(url) },
16
17
  { name: "twitter", test: (url) => /(?:twitter\.com\/|x\.com\/)/i.test(url) },
17
18
  { name: "lahelu", test: (url) => /lahelu\.com\/post\//i.test(url) },
19
+ { name: "xiaohongshu", test: (url) => /xiaohongshu\.com|rednote\.com|xhslink\.com/i.test(url) },
18
20
  ];
19
21
 
20
22
  function detectPlatform(url) {
@@ -32,6 +34,7 @@ const scrapers = {
32
34
  pixiv: (url, options) => pixivDownloader(url, options),
33
35
  twitter: (url) => twitterDownloader(url),
34
36
  lahelu: (url) => laheluDownloader(url),
37
+ xiaohongshu: (url) => xiaohongshuDownloader(url),
35
38
  };
36
39
 
37
40
  async function aioDownloader(url, options = {}) {
@@ -60,6 +63,7 @@ const lmna = {
60
63
  pixiv: (url, options) => pixivDownloader(url, options),
61
64
  twitter: (url) => twitterDownloader(url),
62
65
  lahelu: (url) => laheluDownloader(url),
66
+ xiaohongshu: (url) => xiaohongshuDownloader(url),
63
67
  };
64
68
 
65
69
  module.exports = {
@@ -67,13 +71,4 @@ module.exports = {
67
71
  aioDownloader,
68
72
  detectPlatform,
69
73
  download,
70
- // Legacy named exports (backward compatible)
71
- youtubeDownloader,
72
- youtubePlaylistDownloader,
73
- instagramDownloader,
74
- tiktokDownloader,
75
- pinterestDownloader,
76
- pixivDownloader,
77
- twitterDownloader,
78
- laheluDownloader,
79
74
  };
package/src/lib/lahelu.js CHANGED
@@ -25,22 +25,18 @@ function extractPostId(url) {
25
25
  }
26
26
 
27
27
  async function fetchLaheluData(postId) {
28
- for (let page = 1; page <= 10; page++) {
29
- const apiUrl = `https://lahelu.com/api/post/get-posts?feed=1&page=${page}`;
30
-
31
- const res = await axios.get(apiUrl, {
32
- headers: {
33
- "User-Agent": DEFAULT_UA,
34
- "Referer": "https://lahelu.com/",
35
- },
36
- timeout: 15000,
37
- });
38
-
39
- if (res.data && res.data.postInfos && Array.isArray(res.data.postInfos)) {
40
- const post = res.data.postInfos.find(p => p.postId === postId || p.postID === postId);
41
- if (post) return post;
42
- if (!res.data.hasMore) break;
43
- }
28
+ const apiUrl = `https://lahelu.com/api/post/get?postID=${postId}`;
29
+
30
+ const res = await axios.get(apiUrl, {
31
+ headers: {
32
+ "User-Agent": DEFAULT_UA,
33
+ "Referer": "https://lahelu.com/",
34
+ },
35
+ timeout: 15000,
36
+ });
37
+
38
+ if (res.data && res.data.postInfo) {
39
+ return res.data.postInfo;
44
40
  }
45
41
  return null;
46
42
  }
@@ -49,36 +45,35 @@ function normalizeMedia(post) {
49
45
  const CACHE_URL = "https://cache.lahelu.com/";
50
46
  const medias = [];
51
47
 
52
- if (post.mediaType === "video" || post.type === "video") {
53
- const videoUrl = post.media?.startsWith("http") ? post.media : CACHE_URL + post.media;
54
- medias.push({
55
- type: "video",
56
- format: "mp4",
57
- url: videoUrl,
58
- desc: "Video",
59
- });
60
- } else if (post.mediaType === "image" || post.type === "image") {
61
- const imageUrl = post.media?.startsWith("http") ? post.media : CACHE_URL + post.media;
48
+ if (post.media) {
49
+ const mediaUrl = post.media.startsWith("http") ? post.media : CACHE_URL + post.media;
50
+ const isVideo = mediaUrl.match(/\.(mp4|webm|mov)(\?.*)?$/i) || post.mediaType === 1 || post.type === 1;
51
+ const format = isVideo ? "mp4" : getExtFromUrl(mediaUrl, ".jpg");
52
+
62
53
  medias.push({
63
- type: "image",
64
- format: getExtFromUrl(imageUrl, ".jpg"),
65
- url: imageUrl,
66
- desc: "Image",
54
+ type: isVideo ? "video" : "image",
55
+ format: format,
56
+ url: mediaUrl,
57
+ desc: isVideo ? "Video" : "Image",
67
58
  });
68
59
  }
69
60
 
70
- if (post.content && Array.isArray(post.content) && post.content.length > 0) {
71
- const firstItem = post.content[0];
72
- const mediaUrl = firstItem.value?.startsWith("http") ? firstItem.value : CACHE_URL + firstItem.value;
73
- const isVideo = firstItem.type === "video" || (firstItem.value && firstItem.value.match(/\.(mp4|webm|mov)(\?.*)?$/i));
74
- const mediaType = isVideo ? "video" : "image";
75
- const format = mediaType === "video" ? "mp4" : getExtFromUrl(mediaUrl, ".jpg");
61
+ if (post.content && Array.isArray(post.content)) {
62
+ post.content.forEach((item) => {
63
+ if (!item.value) return;
76
64
 
77
- medias.push({
78
- type: mediaType,
79
- format: format,
80
- url: mediaUrl,
81
- desc: mediaType === "video" ? "Video" : "Image",
65
+ const mediaUrl = item.value.startsWith("http") ? item.value : CACHE_URL + item.value;
66
+ const isVideo = mediaUrl.match(/\.(mp4|webm|mov)(\?.*)?$/i) || item.type === 1 || item.type === 4;
67
+ const format = isVideo ? "mp4" : getExtFromUrl(mediaUrl, ".jpg");
68
+
69
+ if (medias.some(m => m.url === mediaUrl)) return;
70
+
71
+ medias.push({
72
+ type: isVideo ? "video" : "image",
73
+ format: format,
74
+ url: mediaUrl,
75
+ desc: isVideo ? "Video" : "Image",
76
+ });
82
77
  });
83
78
  }
84
79
 
@@ -126,7 +121,7 @@ async function laheluDownloader(url) {
126
121
 
127
122
  const title = post.title || "Untitled";
128
123
  const author = post.userUsername || post.userInfo?.username || "Unknown";
129
- const createdAt = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString("en-US") : "-";
124
+ const createdAt = post.createTime ? new Date(post.createTime).toLocaleDateString("en-US") : "-";
130
125
  const views = parseInt(post.totalViews || 0, 10);
131
126
  const likes = parseInt(post.totalUpvotes || 0, 10);
132
127
  const comments = parseInt(post.totalComments || 0, 10);
@@ -0,0 +1,288 @@
1
+ const axios = require("axios");
2
+ const path = require("path");
3
+ const { sanitizeFileName, getExtFromUrl } = require("../utils");
4
+
5
+ let puppeteer;
6
+ try {
7
+ puppeteer = require("puppeteer");
8
+ } catch (err) { }
9
+
10
+ function isXiaohongshuUrl(url) {
11
+ try {
12
+ const u = new URL(url);
13
+ return u.hostname.includes("xiaohongshu.com") ||
14
+ u.hostname.includes("rednote.com") ||
15
+ u.hostname.includes("xhslink.com");
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function parseNoteId(url) {
22
+ const patterns = [
23
+ /xiaohongshu\.com\/(?:explore|discovery\/item)\/([a-f0-9]+)/i,
24
+ /rednote\.com\/(?:explore|discovery\/item)\/([a-f0-9]+)/i,
25
+ /xhslink\.com\/([a-zA-Z0-9]+)/i,
26
+ ];
27
+
28
+ for (const pattern of patterns) {
29
+ const match = url.match(pattern);
30
+ if (match) return match[1];
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function buildPostUrl(noteIdOrUrl) {
36
+ if (noteIdOrUrl.startsWith("http")) return noteIdOrUrl;
37
+ return `https://www.xiaohongshu.com/explore/${noteIdOrUrl}`;
38
+ }
39
+
40
+ async function resolveShortLink(url) {
41
+ if (!url.includes("xhslink.com")) return url;
42
+
43
+ try {
44
+ const res = await axios.head(url, {
45
+ maxRedirects: 5,
46
+ headers: {
47
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
48
+ },
49
+ });
50
+ return res.request.res.responseUrl || url;
51
+ } catch (e) {
52
+ if (e.response && e.response.headers && e.response.headers.location) {
53
+ return e.response.headers.location;
54
+ }
55
+ }
56
+ return url;
57
+ }
58
+
59
+ async function fetchXiaohongshuData(url) {
60
+ if (!puppeteer) {
61
+ throw new Error("Puppeteer is required for Xiaohongshu downloads. Install it with: npm install puppeteer");
62
+ }
63
+
64
+ let browser = null;
65
+ try {
66
+ browser = await puppeteer.launch({
67
+ headless: "new",
68
+ args: [
69
+ "--disable-blink-features=AutomationControlled",
70
+ "--disable-web-security",
71
+ "--disable-features=IsolateOrigins,site-per-process",
72
+ "--window-size=1920,1080",
73
+ "--no-sandbox",
74
+ "--disable-setuid-sandbox",
75
+ ],
76
+ });
77
+
78
+ const page = await browser.newPage();
79
+ await page.setViewport({ width: 1920, height: 1080 });
80
+
81
+ await page.setUserAgent(
82
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
83
+ );
84
+
85
+ await page.evaluateOnNewDocument(() => {
86
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
87
+ Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
88
+ window.chrome = { runtime: {} };
89
+ });
90
+
91
+ page.setDefaultTimeout(30000);
92
+
93
+ let finalUrl = await resolveShortLink(url);
94
+
95
+ await page.goto(finalUrl, {
96
+ waitUntil: "networkidle2",
97
+ timeout: 30000,
98
+ });
99
+
100
+ await page.waitForFunction(
101
+ () => window.__INITIAL_STATE__ !== undefined,
102
+ { timeout: 15000 }
103
+ );
104
+
105
+ await new Promise((r) => setTimeout(r, 1500));
106
+
107
+ const postData = await page.evaluate(() => {
108
+ const state = window.__INITIAL_STATE__;
109
+ if (!state) return null;
110
+
111
+ const noteMap = state?.note?.noteDetailMap || {};
112
+ const firstKey = Object.keys(noteMap)[0];
113
+ const noteWrapper = noteMap[firstKey];
114
+ const note = noteWrapper?.note || noteWrapper;
115
+
116
+ if (!note) return null;
117
+
118
+ return {
119
+ id: note.noteId || note.id || firstKey || "",
120
+ title: note.title || "",
121
+ desc: note.desc || "",
122
+ type: note.type || "normal",
123
+ author: {
124
+ nickname: note.user?.nickname || "",
125
+ userId: note.user?.userId || "",
126
+ avatar: note.user?.image || note.user?.avatar || "",
127
+ },
128
+ images: (note.imageList || []).map((img) => ({
129
+ url: img.urlDefault || img.url || "",
130
+ livePhoto: img.livePhoto || img.stream?.h264?.[0]?.masterUrl || "",
131
+ width: img.width || 0,
132
+ height: img.height || 0,
133
+ })),
134
+ video: note.video ? {
135
+ url: note.video.media?.stream?.h264?.[0]?.masterUrl ||
136
+ note.video.media?.stream?.h265?.[0]?.masterUrl ||
137
+ note.video.url || "",
138
+ backupUrl: note.video.media?.stream?.h264?.[0]?.backupUrls?.[0] ||
139
+ note.video.media?.stream?.h265?.[0]?.backupUrls?.[0] || "",
140
+ duration: note.video.duration || 0,
141
+ width: note.video.width || note.video.media?.stream?.h264?.[0]?.width || 0,
142
+ height: note.video.height || note.video.media?.stream?.h264?.[0]?.height || 0,
143
+ cover: note.video.image?.firstFrameFileid || note.video.thumbnail || "",
144
+ } : null,
145
+ cover: note.cover?.urlDefault || note.cover?.url || "",
146
+ stats: {
147
+ likes: note.interactInfo?.likedCount || "0",
148
+ collects: note.interactInfo?.collectedCount || "0",
149
+ comments: note.interactInfo?.commentCount || "0",
150
+ shares: note.interactInfo?.shareCount || "0",
151
+ },
152
+ tags: (note.tagList || []).map((tag) => tag.name || "").filter(Boolean),
153
+ publishedAt: note.time || "",
154
+ ipLocation: note.ipLocation || "",
155
+ };
156
+ });
157
+
158
+ await browser.close();
159
+ browser = null;
160
+
161
+ if (!postData) {
162
+ return null;
163
+ }
164
+
165
+ const medias = [];
166
+
167
+ if (postData.video && postData.video.url) {
168
+ medias.push({
169
+ type: "video",
170
+ format: "mp4",
171
+ desc: `Video ${postData.video.width}x${postData.video.height}` +
172
+ (postData.video.duration ? ` (${Math.round(postData.video.duration)}s)` : ""),
173
+ url: postData.video.url,
174
+ backupUrl: postData.video.backupUrl || "",
175
+ });
176
+ }
177
+
178
+ if (postData.images && postData.images.length > 0) {
179
+ postData.images.forEach((img, i) => {
180
+ if (img.url) {
181
+ medias.push({
182
+ type: "image",
183
+ format: "jpg",
184
+ desc: `Image ${i + 1}` + (img.width && img.height ? ` (${img.width}x${img.height})` : ""),
185
+ url: img.url.startsWith("http") ? img.url : `https://sns-img-bd.xhscdn.com/${img.url}`,
186
+ });
187
+ }
188
+ if (img.livePhoto) {
189
+ medias.push({
190
+ type: "video",
191
+ format: "mp4",
192
+ desc: `Live Photo ${i + 1}`,
193
+ url: img.livePhoto,
194
+ });
195
+ }
196
+ });
197
+ }
198
+
199
+ return {
200
+ ...postData,
201
+ medias,
202
+ };
203
+ } catch (err) {
204
+ if (browser) {
205
+ try { await browser.close(); } catch (_) { }
206
+ }
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ async function xiaohongshuDownloader(url) {
212
+ try {
213
+ if (!isXiaohongshuUrl(url)) {
214
+ return {
215
+ status: false,
216
+ platform: "xiaohongshu",
217
+ message: "Invalid Xiaohongshu URL. Make sure it's a valid post URL.",
218
+ };
219
+ }
220
+
221
+ const noteId = parseNoteId(url);
222
+ if (!noteId && !url.includes("xhslink.com")) {
223
+ return {
224
+ status: false,
225
+ platform: "xiaohongshu",
226
+ message: "Could not extract note ID from URL.",
227
+ };
228
+ }
229
+
230
+ const postUrl = buildPostUrl(url);
231
+ const data = await fetchXiaohongshuData(postUrl);
232
+
233
+ if (!data || !data.medias || data.medias.length === 0) {
234
+ return {
235
+ status: false,
236
+ platform: "xiaohongshu",
237
+ message: "No media found in this post. The post may be private or deleted.",
238
+ };
239
+ }
240
+
241
+ const selected = data.medias[0];
242
+ const ext = selected.format.startsWith(".") ? selected.format : "." + selected.format;
243
+ const safeTitle = sanitizeFileName(data.id || noteId || "xhs");
244
+ const fileName = `xhs_${safeTitle}_${selected.type}${ext}`;
245
+
246
+ return {
247
+ status: true,
248
+ platform: "xiaohongshu",
249
+ data: {
250
+ id: data.id || noteId,
251
+ title: data.title || "Untitled",
252
+ description: data.desc || "",
253
+ author: {
254
+ nickname: data.author?.nickname || "Unknown",
255
+ userId: data.author?.userId || "",
256
+ avatar: data.author?.avatar || "",
257
+ },
258
+ type: data.type,
259
+ stats: {
260
+ likes: data.stats?.likes || "0",
261
+ collects: data.stats?.collects || "0",
262
+ comments: data.stats?.comments || "0",
263
+ shares: data.stats?.shares || "0",
264
+ },
265
+ tags: data.tags || [],
266
+ publishedAt: data.publishedAt,
267
+ ipLocation: data.ipLocation,
268
+ media: {
269
+ type: selected.type,
270
+ format: selected.format,
271
+ url: selected.url,
272
+ backupUrl: selected.backupUrl || "",
273
+ desc: selected.desc,
274
+ },
275
+ allMedias: data.medias,
276
+ fileName: fileName,
277
+ },
278
+ };
279
+ } catch (error) {
280
+ return {
281
+ status: false,
282
+ platform: "xiaohongshu",
283
+ message: error.message || "An unexpected error occurred",
284
+ };
285
+ }
286
+ }
287
+
288
+ module.exports = xiaohongshuDownloader;
@@ -34,7 +34,7 @@ async function youtubeDownloader(link, qualityIndex) {
34
34
  return {
35
35
  status: false,
36
36
  platform: "youtube",
37
- message: `youtube-dl-exec not found, can't download video`,
37
+ message: "youtube-dl-exec is not installed. Install it with: npm install youtube-dl-exec",
38
38
  };
39
39
  }
40
40
  try {