umedia 0.4.0 → 0.4.1
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 +1 -1
- package/lib/providers/snapchat.js +110 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ trailers, social posts with official embeds. `previewKind: "none"` beats a fake
|
|
|
88
88
|
| **X** | — | ✅ | ✅ | syndication token + guest GraphQL: photos/videos/gifs at real bitrates |
|
|
89
89
|
| **Facebook** | — | ✅ | ✅ | reels/watch/videos/shares/fb.watch via the web player JSON |
|
|
90
90
|
| **Threads** | — | ✅ | ✅ | post SSR state + og media (Meta gates some networks — typed honest error) |
|
|
91
|
-
| **Snapchat** | — | ✅
|
|
91
|
+
| **Snapchat** | — | ✅ | ✅ | spotlight videos (direct sc-cdn mp4) + public stories/highlights — ALL snaps in order |
|
|
92
92
|
| **Rumble** | — | ✅ best-effort | ✅ best-effort | embed API direct mp4s (site blocks datacenter IPs) |
|
|
93
93
|
| **Tumblr** | — | ✅ | ✅ | mobile API: video/audio/photo posts + reblog trails, all in order |
|
|
94
94
|
| **iTunes** | ✅ | — | ✅ previews | real 30s clips, rock solid |
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchText, hostMatches } from "../util.js";
|
|
2
2
|
import { Codes, UMediaError } from "../errors.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Snapchat — Spotlight videos
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Snapchat — Spotlight videos and public story snap lists.
|
|
6
|
+
* Primary: the page's __NEXT_DATA__ -> spotlightFeed.spotlightStories, matched
|
|
7
|
+
* by storyId (the exact structure yt-dlp's snapchat extractor reads) ->
|
|
8
|
+
* videoMetadata.contentUrl (direct sc-cdn mp4). Fallback: the preload video
|
|
9
|
+
* link (cobalt). Stories: pageProps.story.snapList — ALL snaps, in order.
|
|
8
10
|
*/
|
|
9
11
|
export const name = "snapchat";
|
|
10
12
|
|
|
11
|
-
const SPOTLIGHT_PRELOAD = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
|
12
13
|
const NEXT_DATA = /<script id="__NEXT_DATA__" type="application\/json">({.+?})<\/script>/;
|
|
14
|
+
const PRELOAD_VIDEO = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
|
13
15
|
|
|
14
16
|
export function canHandle(url) {
|
|
15
17
|
try {
|
|
@@ -17,6 +19,32 @@ export function canHandle(url) {
|
|
|
17
19
|
} catch { return false; }
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
export function spotlightId(url) {
|
|
23
|
+
const m = String(url).match(/\/spotlight\/([\w-]{10,120})/);
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nextData(html) {
|
|
28
|
+
const m = html.match(NEXT_DATA);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
try { return JSON.parse(m[1]); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pushStorySnap(snap, media) {
|
|
34
|
+
const isPhoto = snap.snapMediaType === 0;
|
|
35
|
+
const u = snap.snapUrls?.mediaUrl;
|
|
36
|
+
if (!u) return;
|
|
37
|
+
media.push({
|
|
38
|
+
type: isPhoto ? "image" : "video", index: media.length, url: u,
|
|
39
|
+
thumbnail: snap.snapUrls?.mediaPreviewUrl?.value || u,
|
|
40
|
+
mimeType: isPhoto ? "image/jpeg" : "video/mp4",
|
|
41
|
+
width: null, height: null,
|
|
42
|
+
duration: snap.durationInMs ? Math.round(snap.durationInMs / 1000) : (snap.duration ? Math.round(snap.duration / 1000) : null),
|
|
43
|
+
size: null, quality: null,
|
|
44
|
+
hasAudio: !isPhoto, hasVideo: !isPhoto, source: name, variants: [],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
export async function resolve(url, ctx = {}) {
|
|
21
49
|
const attempt = { provider: name, status: "skipped" };
|
|
22
50
|
ctx.attempts?.push(attempt);
|
|
@@ -33,53 +61,92 @@ export async function resolve(url, ctx = {}) {
|
|
|
33
61
|
const media = [];
|
|
34
62
|
let title = null;
|
|
35
63
|
let author = null;
|
|
64
|
+
let duration = null;
|
|
36
65
|
|
|
37
|
-
const
|
|
66
|
+
const sid = spotlightId(target);
|
|
38
67
|
const story = target.match(/\/add\/([\w.-]+)(?:\/([\w-]+))?/i);
|
|
39
68
|
|
|
40
|
-
if (
|
|
41
|
-
const r = await fetchText(`https://www.snapchat.com/spotlight/${
|
|
42
|
-
const
|
|
43
|
-
|
|
69
|
+
if (sid) {
|
|
70
|
+
const r = await fetchText(`https://www.snapchat.com/spotlight/${sid}`, { headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" } }, 20_000).catch(() => null);
|
|
71
|
+
const html = r?.text || "";
|
|
72
|
+
|
|
73
|
+
// primary: __NEXT_DATA__ spotlight feed entry matched by storyId (yt-dlp structure)
|
|
74
|
+
const d = nextData(html);
|
|
75
|
+
const stories = d?.props?.pageProps?.spotlightFeed?.spotlightStories || [];
|
|
76
|
+
const entry = stories.find((s) => s?.story?.storyId?.value === sid);
|
|
77
|
+
const vm = entry?.metadata?.videoMetadata;
|
|
78
|
+
if (vm?.contentUrl) {
|
|
44
79
|
media.push({
|
|
45
|
-
type: "video", index: 0, url:
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
type: "video", index: 0, url: vm.contentUrl,
|
|
81
|
+
thumbnail: vm.thumbnailUrl || null,
|
|
82
|
+
mimeType: "video/mp4",
|
|
83
|
+
width: vm.width ?? null, height: vm.height ?? null,
|
|
84
|
+
duration: vm.durationMs ? Math.round(vm.durationMs / 1000) : null,
|
|
85
|
+
size: null, quality: vm.height ? `${vm.height}p` : null,
|
|
86
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
48
87
|
});
|
|
49
|
-
title =
|
|
88
|
+
title = vm.name || null;
|
|
89
|
+
author = vm.creator?.personCreator?.username || vm.creator?.username || null;
|
|
90
|
+
duration = vm.durationMs ? Math.round(vm.durationMs / 1000) : null;
|
|
50
91
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const data = JSON.parse(raw);
|
|
57
|
-
author = story[1];
|
|
58
|
-
const snaps = data?.props?.pageProps?.story?.snapList
|
|
59
|
-
|| data?.props?.pageProps?.curatedHighlights?.[0]?.snapList
|
|
60
|
-
|| [];
|
|
61
|
-
snaps.forEach((snap) => {
|
|
62
|
-
const isPhoto = snap.snapMediaType === 0;
|
|
63
|
-
const u = snap.snapUrls?.mediaUrl;
|
|
64
|
-
if (!u) return;
|
|
92
|
+
|
|
93
|
+
// fallback: the preload video link (cobalt's route)
|
|
94
|
+
if (!media.length) {
|
|
95
|
+
const v = html.match(PRELOAD_VIDEO)?.[1];
|
|
96
|
+
if (v && /^https?:/.test(v) && new URL(v).hostname.endsWith("sc-cdn.net")) {
|
|
65
97
|
media.push({
|
|
66
|
-
type:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
duration: snap.duration ? Math.round(snap.duration / 1000) : null,
|
|
71
|
-
size: null, quality: null,
|
|
72
|
-
hasAudio: !isPhoto, hasVideo: !isPhoto, source: name, variants: [],
|
|
98
|
+
type: "video", index: 0, url: v, thumbnail: null,
|
|
99
|
+
mimeType: "video/mp4", width: null, height: null,
|
|
100
|
+
duration: null, size: null, quality: null,
|
|
101
|
+
hasAudio: true, hasVideo: true, source: name, variants: [],
|
|
73
102
|
});
|
|
74
|
-
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (story) {
|
|
106
|
+
const r = await fetchText(`https://www.snapchat.com/add/${story[1]}${story[2] ? "/" + story[2] : ""}`,
|
|
107
|
+
{ headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" } }, 20_000).catch(() => null);
|
|
108
|
+
const d = nextData(r?.text || "");
|
|
109
|
+
const pp = d?.props?.pageProps || {};
|
|
110
|
+
author = story[1];
|
|
111
|
+
|
|
112
|
+
const activeStory = pp.story?.snapList || [];
|
|
113
|
+
const highlights = pp.spotlightHighlights || [];
|
|
114
|
+
const curated = pp.curatedHighlights || [];
|
|
115
|
+
|
|
116
|
+
if (story[2]) {
|
|
117
|
+
// a single snap (by snapId) or one highlight reel (by storyId/highlightId)
|
|
118
|
+
const wanted = story[2];
|
|
119
|
+
const allLists = [activeStory, ...curated.map((c) => c.snapList || []), ...highlights.map((h) => h.snapList || [])];
|
|
120
|
+
for (const list of allLists) {
|
|
121
|
+
const snap = list.find((s) => s.snapId?.value === wanted || s.snapId === wanted);
|
|
122
|
+
if (snap) { pushStorySnap(snap, media); break; }
|
|
123
|
+
}
|
|
124
|
+
if (!media.length) {
|
|
125
|
+
const hl = highlights.find((h) => h.storyId === wanted || h.highlightId === wanted || h.storyShareId === wanted);
|
|
126
|
+
if (hl) {
|
|
127
|
+
title = hl.storyTitle || null;
|
|
128
|
+
(hl.snapList || []).forEach((snap) => pushStorySnap(snap, media));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!media.length) {
|
|
133
|
+
// bare /add/{user}: the current story if it exists, else their public
|
|
134
|
+
// highlight reels — ALL snaps, in order, nothing silently dropped
|
|
135
|
+
const list = activeStory.length
|
|
136
|
+
? activeStory
|
|
137
|
+
: (curated[0]?.snapList?.length ? curated[0].snapList : highlights.flatMap((h) => h.snapList || []));
|
|
138
|
+
if (!activeStory.length && highlights.length && !curated[0]?.snapList?.length) {
|
|
139
|
+
title = `${author} — ${highlights.length} public highlight${highlights.length > 1 ? "s" : ""}`;
|
|
140
|
+
}
|
|
141
|
+
list.forEach((snap) => pushStorySnap(snap, media));
|
|
75
142
|
}
|
|
76
143
|
}
|
|
77
144
|
|
|
78
145
|
if (!media.length) {
|
|
79
146
|
attempt.status = "failed";
|
|
80
|
-
attempt.error = "no snap media exposed (removed, private, or
|
|
147
|
+
attempt.error = "no snap media exposed (removed, private, or an expired spotlight)";
|
|
81
148
|
throw new UMediaError(Codes.MEDIA_NOT_FOUND,
|
|
82
|
-
`Snapchat exposes no media for this URL (${attempt.error}) —
|
|
149
|
+
`Snapchat exposes no media for this URL (${attempt.error}) — public spotlight links and stories work; removed snaps are gone from Snapchat itself.`, { attempts: [attempt] });
|
|
83
150
|
}
|
|
84
151
|
|
|
85
152
|
attempt.status = "success";
|
|
@@ -92,7 +159,11 @@ export async function resolve(url, ctx = {}) {
|
|
|
92
159
|
author,
|
|
93
160
|
thumbnail: media[0].thumbnail,
|
|
94
161
|
itemCount: media.length,
|
|
95
|
-
counts: {
|
|
162
|
+
counts: {
|
|
163
|
+
images: media.filter((m) => m.type === "image").length,
|
|
164
|
+
videos: media.filter((m) => m.type === "video").length,
|
|
165
|
+
audios: 0, other: 0,
|
|
166
|
+
},
|
|
96
167
|
truncated: false,
|
|
97
168
|
media,
|
|
98
169
|
streamUrlsAvailable: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umedia",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Standalone media engine — search, resolve and download media from YouTube, TikTok, Instagram, Reddit, X, iTunes (and 1800+ sites via the yt-dlp tier) entirely on your machine. No hosted API, no keys, no server bills. Honest quality labels, complete galleries, typed errors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|