@postfetch/core 0.6.0 → 0.8.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 +13 -4
- package/dist/download.d.ts +32 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +381 -39
- package/dist/internal.d.ts +13 -0
- package/dist/mp4-remux.d.ts +18 -0
- package/dist/postfetch.d.ts +12 -2
- package/package.json +1 -1
- package/src/download.ts +68 -0
- package/src/facebook.ts +120 -19
- package/src/index.ts +12 -3
- package/src/internal.ts +12 -0
- package/src/mp4-remux.ts +100 -0
- package/src/postfetch.ts +85 -5
- package/src/twitter.ts +140 -21
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
1
4
|
// src/internal.ts
|
|
2
5
|
class PostfetchError extends Error {
|
|
3
6
|
status;
|
|
@@ -162,13 +165,18 @@ function instagramAppUserAgent() {
|
|
|
162
165
|
|
|
163
166
|
// src/facebook.ts
|
|
164
167
|
async function resolveFacebook(input) {
|
|
165
|
-
const
|
|
168
|
+
const page = await canonicalPage(input.net, normalizedInputUrl(input.url));
|
|
169
|
+
const canonical = page.url;
|
|
166
170
|
const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
|
|
167
|
-
const
|
|
168
|
-
const
|
|
171
|
+
const pageMetadata = facebookMetadata(page.html);
|
|
172
|
+
const embed = await embedVideo(input.net, canonical, input.preferredWidth);
|
|
173
|
+
const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
|
|
169
174
|
const url = embed ?? watch?.url ?? null;
|
|
170
175
|
if (!url) {
|
|
171
|
-
|
|
176
|
+
if (pageMetadata?.text) {
|
|
177
|
+
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
178
|
+
}
|
|
179
|
+
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
172
180
|
}
|
|
173
181
|
const item = {
|
|
174
182
|
filename: filename(`facebook_${id}.mp4`),
|
|
@@ -179,7 +187,18 @@ async function resolveFacebook(input) {
|
|
|
179
187
|
platform: "facebook",
|
|
180
188
|
url
|
|
181
189
|
};
|
|
182
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
archiveFilename: filename(`facebook_${id}.zip`),
|
|
192
|
+
id,
|
|
193
|
+
items: [item],
|
|
194
|
+
metadata: mergeMetadata(pageMetadata, watch?.metadata),
|
|
195
|
+
platform: "facebook"
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function normalizedInputUrl(input) {
|
|
199
|
+
const url = asUrl(input);
|
|
200
|
+
const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
|
|
201
|
+
return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
|
|
183
202
|
}
|
|
184
203
|
function navigationHeaders2() {
|
|
185
204
|
return {
|
|
@@ -188,30 +207,38 @@ function navigationHeaders2() {
|
|
|
188
207
|
"user-agent": browserUserAgent()
|
|
189
208
|
};
|
|
190
209
|
}
|
|
191
|
-
async function
|
|
210
|
+
async function canonicalPage(net, input) {
|
|
192
211
|
const response = await net(input, { headers: navigationHeaders2() });
|
|
193
|
-
const resolved = asUrl(response.url);
|
|
194
|
-
return
|
|
212
|
+
const resolved = asUrl(response.url || input);
|
|
213
|
+
return {
|
|
214
|
+
html: response.ok ? await response.text() : "",
|
|
215
|
+
url: `${resolved.origin}${resolved.pathname}`
|
|
216
|
+
};
|
|
195
217
|
}
|
|
196
|
-
async function embedVideo(net, canonical) {
|
|
218
|
+
async function embedVideo(net, canonical, preferredWidth) {
|
|
197
219
|
const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
|
|
198
220
|
const response = await net(embed, { headers: navigationHeaders2() });
|
|
199
221
|
if (!response.ok) {
|
|
200
222
|
return null;
|
|
201
223
|
}
|
|
202
224
|
const html = await response.text();
|
|
203
|
-
return source(html, "hd_src")
|
|
225
|
+
return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
|
|
204
226
|
}
|
|
205
|
-
async function watchVideo(net, id) {
|
|
227
|
+
async function watchVideo(net, id, preferredWidth) {
|
|
206
228
|
const headers = { ...navigationHeaders2(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
|
|
207
229
|
const response = await net(`https://www.facebook.com/watch/?v=${id}&_rdr`, { headers });
|
|
208
230
|
if (!response.ok) {
|
|
209
231
|
return null;
|
|
210
232
|
}
|
|
211
233
|
const html = await response.text();
|
|
212
|
-
const
|
|
234
|
+
const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
|
|
235
|
+
const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
|
|
236
|
+
const url = preferredSource(preferredWidth, hd, sd);
|
|
213
237
|
return url ? { url, metadata: watchMetadata(html) } : null;
|
|
214
238
|
}
|
|
239
|
+
function preferredSource(preferredWidth, hd, sd) {
|
|
240
|
+
return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
|
|
241
|
+
}
|
|
215
242
|
function watchMetadata(html) {
|
|
216
243
|
const owner = html.match(/"name":("(?:\\.|[^"\\])*"),"__isVideoOwner"/);
|
|
217
244
|
const name = owner ? JSON.parse(owner[1]) : undefined;
|
|
@@ -221,6 +248,63 @@ function watchMetadata(html) {
|
|
|
221
248
|
viewCount: count(html.match(/"video_view_count":(\d+)/)?.[1])
|
|
222
249
|
};
|
|
223
250
|
}
|
|
251
|
+
function facebookMetadata(html) {
|
|
252
|
+
const canonical = metaContent(html, "og:url");
|
|
253
|
+
if (!canonical || !facebookPostUrl(canonical)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const text = metaContent(html, "og:description") ?? metaContent(html, "description");
|
|
257
|
+
const name = metaContent(html, "og:title");
|
|
258
|
+
if (!text && !name) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
text: text ?? undefined,
|
|
263
|
+
author: name ? { name } : undefined
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function facebookPostUrl(input) {
|
|
267
|
+
try {
|
|
268
|
+
const url = new URL(input);
|
|
269
|
+
return /\/posts\/(?:[^/]+\/)?[A-Za-z0-9]+\/?$/.test(url.pathname) || /\/share\/p\/[A-Za-z0-9]+\/?$/.test(url.pathname) || (url.pathname === "/permalink.php" || url.pathname === "/story.php") && Boolean(url.searchParams.get("story_fbid"));
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function metaContent(html, key) {
|
|
275
|
+
for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
276
|
+
const tag = match[0];
|
|
277
|
+
if ((attribute(tag, "property") ?? attribute(tag, "name")) !== key) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const content = attribute(tag, "content");
|
|
281
|
+
return content ? decodeHtml(content) : null;
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
function attribute(tag, name) {
|
|
286
|
+
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "is"));
|
|
287
|
+
return match?.[2] ?? null;
|
|
288
|
+
}
|
|
289
|
+
function decodeHtml(input) {
|
|
290
|
+
const named = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"' };
|
|
291
|
+
return input.replace(/&(#x[\da-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, (entity, code) => {
|
|
292
|
+
if (code[0] !== "#") {
|
|
293
|
+
return named[code.toLowerCase()] ?? entity;
|
|
294
|
+
}
|
|
295
|
+
const value = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
|
|
296
|
+
return Number.isSafeInteger(value) && value <= 1114111 ? String.fromCodePoint(value) : entity;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function mergeMetadata(page, watch) {
|
|
300
|
+
if (!page) {
|
|
301
|
+
return watch;
|
|
302
|
+
}
|
|
303
|
+
if (!watch) {
|
|
304
|
+
return page;
|
|
305
|
+
}
|
|
306
|
+
return { ...page, ...watch, author: watch.author ?? page.author };
|
|
307
|
+
}
|
|
224
308
|
function source(html, key) {
|
|
225
309
|
const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
|
|
226
310
|
if (!match) {
|
|
@@ -799,7 +883,7 @@ function parseJson(value) {
|
|
|
799
883
|
return JSON.parse(value);
|
|
800
884
|
} catch {
|
|
801
885
|
try {
|
|
802
|
-
return JSON.parse(
|
|
886
|
+
return JSON.parse(decodeHtml2(value));
|
|
803
887
|
} catch {
|
|
804
888
|
return null;
|
|
805
889
|
}
|
|
@@ -834,7 +918,7 @@ function bestVideoUrl(html) {
|
|
|
834
918
|
}
|
|
835
919
|
let parsed;
|
|
836
920
|
try {
|
|
837
|
-
parsed = JSON.parse(
|
|
921
|
+
parsed = JSON.parse(decodeHtml2(encoded));
|
|
838
922
|
} catch {
|
|
839
923
|
return null;
|
|
840
924
|
}
|
|
@@ -871,7 +955,7 @@ function linkedinHandle(value) {
|
|
|
871
955
|
return null;
|
|
872
956
|
}
|
|
873
957
|
}
|
|
874
|
-
function
|
|
958
|
+
function decodeHtml2(value) {
|
|
875
959
|
return value.replace(/&(quot|amp|lt|gt|#39|#x27|#(\d+)|#x([0-9a-f]+));/gi, (_entity, name, decimal, hex) => {
|
|
876
960
|
switch (name.toLowerCase()) {
|
|
877
961
|
case "quot":
|
|
@@ -1531,7 +1615,7 @@ function extensionOf(mime) {
|
|
|
1531
1615
|
|
|
1532
1616
|
// src/soundcloud.ts
|
|
1533
1617
|
async function resolveSoundcloud(input) {
|
|
1534
|
-
const url = await
|
|
1618
|
+
const url = await canonicalUrl(input.net, input.url);
|
|
1535
1619
|
const clientId = await appClientId(input.net);
|
|
1536
1620
|
const track = await resolveTrack(input.net, url, clientId);
|
|
1537
1621
|
const id = trackId(track);
|
|
@@ -1557,7 +1641,7 @@ function soundcloudMetadata(track) {
|
|
|
1557
1641
|
}
|
|
1558
1642
|
};
|
|
1559
1643
|
}
|
|
1560
|
-
async function
|
|
1644
|
+
async function canonicalUrl(net, input) {
|
|
1561
1645
|
const host = asUrl(input).hostname;
|
|
1562
1646
|
if (host !== "on.soundcloud.com" && host !== "snd.sc") {
|
|
1563
1647
|
return input;
|
|
@@ -1925,15 +2009,28 @@ async function resolveTwitter(input) {
|
|
|
1925
2009
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
1926
2010
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
1927
2011
|
}
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
2012
|
+
const groups = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
|
|
2013
|
+
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
2014
|
+
return media.flatMap((entry, index) => {
|
|
2015
|
+
const candidates = twitterCandidates(entry, postId2, index + 1);
|
|
2016
|
+
return candidates.length > 0 ? [candidates] : [];
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
|
|
1933
2020
|
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
1934
2021
|
}
|
|
1935
2022
|
function twitterMetadata(tweet) {
|
|
1936
2023
|
const user = object(tweet.user) ? tweet.user : null;
|
|
2024
|
+
const extra = {};
|
|
2025
|
+
const lang = string(tweet.lang);
|
|
2026
|
+
if (lang) {
|
|
2027
|
+
extra.lang = lang;
|
|
2028
|
+
}
|
|
2029
|
+
const quotedTweet = object(tweet.quoted_tweet) ? tweet.quoted_tweet : null;
|
|
2030
|
+
const quotedId = quotedTweet ? string(quotedTweet.id_str) : null;
|
|
2031
|
+
if (quotedTweet && quotedId) {
|
|
2032
|
+
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
2033
|
+
}
|
|
1937
2034
|
return {
|
|
1938
2035
|
text: string(tweet.text) ?? string(tweet.full_text) ?? undefined,
|
|
1939
2036
|
author: user ? {
|
|
@@ -1947,9 +2044,26 @@ function twitterMetadata(tweet) {
|
|
|
1947
2044
|
shareCount: count(tweet.retweet_count),
|
|
1948
2045
|
viewCount: count(tweet.view_count),
|
|
1949
2046
|
nsfw: bool(tweet.possibly_sensitive),
|
|
1950
|
-
extra
|
|
2047
|
+
extra
|
|
1951
2048
|
};
|
|
1952
2049
|
}
|
|
2050
|
+
function twitterTweets(tweet, rootId) {
|
|
2051
|
+
const tweets = [];
|
|
2052
|
+
const seen = new Set;
|
|
2053
|
+
let current = tweet;
|
|
2054
|
+
let fallbackId = rootId;
|
|
2055
|
+
while (current) {
|
|
2056
|
+
const id = string(current.id_str) ?? fallbackId;
|
|
2057
|
+
if (!id || seen.has(id)) {
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
tweets.push({ id, tweet: current });
|
|
2061
|
+
seen.add(id);
|
|
2062
|
+
current = object(current.quoted_tweet) ? current.quoted_tweet : null;
|
|
2063
|
+
fallbackId = null;
|
|
2064
|
+
}
|
|
2065
|
+
return tweets;
|
|
2066
|
+
}
|
|
1953
2067
|
async function syndication(net, id) {
|
|
1954
2068
|
const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${syndicationToken(id)}`;
|
|
1955
2069
|
const response = await net(url, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
|
|
@@ -1965,25 +2079,92 @@ async function syndication(net, id) {
|
|
|
1965
2079
|
function syndicationToken(id) {
|
|
1966
2080
|
return (Number(id) / 1000000000000000 * Math.PI).toString(36).replace(/(0+|\.)/g, "");
|
|
1967
2081
|
}
|
|
1968
|
-
function
|
|
2082
|
+
function twitterCandidates(entry, id, index) {
|
|
1969
2083
|
const headers = { "user-agent": browserUserAgent() };
|
|
1970
2084
|
const type = string(entry.type);
|
|
1971
2085
|
if (type === "video" || type === "animated_gif") {
|
|
1972
|
-
|
|
1973
|
-
|
|
2086
|
+
return videoVariants(entry).map(({ bitrate, url }) => ({
|
|
2087
|
+
bitrate,
|
|
2088
|
+
item: { filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }
|
|
2089
|
+
}));
|
|
1974
2090
|
}
|
|
1975
2091
|
const photo = string(entry.media_url_https);
|
|
1976
|
-
return photo ? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }] : [];
|
|
2092
|
+
return photo ? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }] : [];
|
|
1977
2093
|
}
|
|
1978
|
-
function
|
|
2094
|
+
function videoVariants(entry) {
|
|
1979
2095
|
const info = object(entry.video_info) ? entry.video_info : null;
|
|
1980
2096
|
const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2097
|
+
const seen = new Set;
|
|
2098
|
+
return variants.flatMap((variant) => {
|
|
2099
|
+
const url = string(variant.url);
|
|
2100
|
+
if (string(variant.content_type) !== "video/mp4" || !url || seen.has(url)) {
|
|
2101
|
+
return [];
|
|
2102
|
+
}
|
|
2103
|
+
seen.add(url);
|
|
2104
|
+
return [{ bitrate: number(variant.bitrate) ?? 0, url }];
|
|
2105
|
+
}).sort((left, right) => right.bitrate - left.bitrate);
|
|
2106
|
+
}
|
|
2107
|
+
async function sizeLimitedItems(input, groups) {
|
|
2108
|
+
if (groups.length === 0 || input.tryMaxBytes === undefined) {
|
|
2109
|
+
return groups.map((group) => group[0].item);
|
|
2110
|
+
}
|
|
2111
|
+
const sizes = new Map;
|
|
2112
|
+
const size = (candidate) => {
|
|
2113
|
+
const existing = sizes.get(candidate.item.url);
|
|
2114
|
+
if (existing) {
|
|
2115
|
+
return existing;
|
|
2116
|
+
}
|
|
2117
|
+
const pending = contentLength(input.net, candidate.item);
|
|
2118
|
+
sizes.set(candidate.item.url, pending);
|
|
2119
|
+
return pending;
|
|
2120
|
+
};
|
|
2121
|
+
const highest = groups.map((group) => group[0]);
|
|
2122
|
+
const highestSizes = await Promise.all(highest.map(size));
|
|
2123
|
+
const highestTotal = knownTotal(highestSizes);
|
|
2124
|
+
if (highestTotal !== null && highestTotal <= input.tryMaxBytes) {
|
|
2125
|
+
return highest.map(({ item }) => item);
|
|
2126
|
+
}
|
|
2127
|
+
const sized = await Promise.all(groups.map(async (group) => Promise.all(group.map(async (candidate) => ({ ...candidate, bytes: await size(candidate) })))));
|
|
2128
|
+
const fitting = bestFitting(sized, input.tryMaxBytes);
|
|
2129
|
+
return (fitting ?? groups.map((group) => group.at(-1))).map(({ item }) => item);
|
|
2130
|
+
}
|
|
2131
|
+
function knownTotal(sizes) {
|
|
2132
|
+
return sizes.some((size) => size === null) ? null : sizes.reduce((total, size) => total + (size ?? 0), 0);
|
|
2133
|
+
}
|
|
2134
|
+
function bestFitting(groups, maxBytes) {
|
|
2135
|
+
let selections = [
|
|
2136
|
+
{ bytes: 0, candidates: [], quality: 0 }
|
|
2137
|
+
];
|
|
2138
|
+
for (const group of groups) {
|
|
2139
|
+
selections = selections.flatMap((selection) => group.flatMap((candidate) => {
|
|
2140
|
+
if (candidate.bytes === null || selection.bytes + candidate.bytes > maxBytes) {
|
|
2141
|
+
return [];
|
|
2142
|
+
}
|
|
2143
|
+
return [{
|
|
2144
|
+
bytes: selection.bytes + candidate.bytes,
|
|
2145
|
+
candidates: [...selection.candidates, candidate],
|
|
2146
|
+
quality: selection.quality + candidate.bitrate
|
|
2147
|
+
}];
|
|
2148
|
+
}));
|
|
2149
|
+
if (selections.length === 0) {
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const best = selections.reduce((current, selection) => selection.quality > current.quality ? selection : current);
|
|
2154
|
+
return best.candidates;
|
|
2155
|
+
}
|
|
2156
|
+
async function contentLength(net, item) {
|
|
2157
|
+
try {
|
|
2158
|
+
const response = await net(item.url, { headers: item.headers, method: "HEAD" }, 1);
|
|
2159
|
+
const raw = response.ok ? response.headers.get("content-length") : null;
|
|
2160
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
2161
|
+
return null;
|
|
2162
|
+
}
|
|
2163
|
+
const bytes = Number(raw);
|
|
2164
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
2165
|
+
} catch {
|
|
2166
|
+
return null;
|
|
2167
|
+
}
|
|
1987
2168
|
}
|
|
1988
2169
|
function tweetId(input) {
|
|
1989
2170
|
return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
|
|
@@ -2215,12 +2396,20 @@ async function postfetch(url, options = {}) {
|
|
|
2215
2396
|
if (trimmed.length === 0) {
|
|
2216
2397
|
throw new PostfetchError(400, "url is required");
|
|
2217
2398
|
}
|
|
2399
|
+
if (options.tryMaxBytes !== undefined && (!Number.isSafeInteger(options.tryMaxBytes) || options.tryMaxBytes <= 0)) {
|
|
2400
|
+
throw new PostfetchError(400, "tryMaxBytes must be a positive integer");
|
|
2401
|
+
}
|
|
2218
2402
|
const context = {
|
|
2219
2403
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
2220
2404
|
preferredWidth: options.preferredWidth ?? 720,
|
|
2405
|
+
tryMaxBytes: options.tryMaxBytes,
|
|
2221
2406
|
url: trimmed
|
|
2222
2407
|
};
|
|
2223
|
-
|
|
2408
|
+
const result = await resolve(context);
|
|
2409
|
+
return options.tryMaxBytes === undefined || result.platform === "twitter" ? result : trySmaller(context, result, options.tryMaxBytes);
|
|
2410
|
+
}
|
|
2411
|
+
async function resolve(context) {
|
|
2412
|
+
switch (detect(context.url)) {
|
|
2224
2413
|
case "facebook":
|
|
2225
2414
|
return resolveFacebook(context);
|
|
2226
2415
|
case "instagram":
|
|
@@ -2241,6 +2430,58 @@ async function postfetch(url, options = {}) {
|
|
|
2241
2430
|
return resolveYoutube(context);
|
|
2242
2431
|
}
|
|
2243
2432
|
}
|
|
2433
|
+
async function trySmaller(context, result, maxBytes) {
|
|
2434
|
+
const originalBytes = await resultBytes(context, result);
|
|
2435
|
+
if (originalBytes === null || originalBytes <= maxBytes) {
|
|
2436
|
+
return result;
|
|
2437
|
+
}
|
|
2438
|
+
try {
|
|
2439
|
+
const smaller = await resolve({ ...context, preferredWidth: 1 });
|
|
2440
|
+
if (sameMedia(result, smaller)) {
|
|
2441
|
+
return result;
|
|
2442
|
+
}
|
|
2443
|
+
const smallerBytes = await resultBytes(context, smaller);
|
|
2444
|
+
return smallerBytes !== null && smallerBytes < originalBytes ? smaller : result;
|
|
2445
|
+
} catch {
|
|
2446
|
+
return result;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
async function resultBytes(context, result) {
|
|
2450
|
+
const sizes = await Promise.all(result.items.map((item) => itemBytes(context, item)));
|
|
2451
|
+
if (sizes.some((size) => size === null)) {
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
return sizes.reduce((total, size) => total + (size ?? 0), 0);
|
|
2455
|
+
}
|
|
2456
|
+
async function itemBytes(context, item) {
|
|
2457
|
+
if (item.hls) {
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
const [video, audio] = await Promise.all([
|
|
2461
|
+
contentLength2(context, item.url, item.headers),
|
|
2462
|
+
item.audio ? contentLength2(context, item.audio.url, item.audio.headers) : Promise.resolve(0)
|
|
2463
|
+
]);
|
|
2464
|
+
return video === null || audio === null ? null : video + audio;
|
|
2465
|
+
}
|
|
2466
|
+
async function contentLength2(context, url, headers) {
|
|
2467
|
+
try {
|
|
2468
|
+
const response = await context.net(url, { headers, method: "HEAD" }, 1);
|
|
2469
|
+
const raw = response.ok ? response.headers.get("content-length") : null;
|
|
2470
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
2471
|
+
return null;
|
|
2472
|
+
}
|
|
2473
|
+
const bytes = Number(raw);
|
|
2474
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
2475
|
+
} catch {
|
|
2476
|
+
return null;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
function sameMedia(left, right) {
|
|
2480
|
+
return left.items.length === right.items.length && left.items.every((item, index) => {
|
|
2481
|
+
const other = right.items[index];
|
|
2482
|
+
return item.url === other?.url && item.audio?.url === other.audio?.url;
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2244
2485
|
function detect(url) {
|
|
2245
2486
|
const host = asUrl(url).hostname;
|
|
2246
2487
|
if (host.includes("tiktok.com")) {
|
|
@@ -2272,6 +2513,83 @@ function detect(url) {
|
|
|
2272
2513
|
}
|
|
2273
2514
|
throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
2274
2515
|
}
|
|
2516
|
+
// src/mp4-remux.ts
|
|
2517
|
+
async function remuxMp4(bytes, ffmpegPath = "ffmpeg", runtime) {
|
|
2518
|
+
let directory;
|
|
2519
|
+
let io;
|
|
2520
|
+
try {
|
|
2521
|
+
io = runtime ?? await nodeRuntime();
|
|
2522
|
+
directory = await io.makeTempDir("postfetch-remux-");
|
|
2523
|
+
const inputPath = `${directory}/input.mp4`;
|
|
2524
|
+
const outputPath = `${directory}/output.mp4`;
|
|
2525
|
+
await io.writeFile(inputPath, bytes);
|
|
2526
|
+
const result = await io.run(ffmpegPath, [
|
|
2527
|
+
"-nostdin",
|
|
2528
|
+
"-hide_banner",
|
|
2529
|
+
"-loglevel",
|
|
2530
|
+
"error",
|
|
2531
|
+
"-y",
|
|
2532
|
+
"-i",
|
|
2533
|
+
inputPath,
|
|
2534
|
+
"-map",
|
|
2535
|
+
"0:v:0",
|
|
2536
|
+
"-map",
|
|
2537
|
+
"0:a:0?",
|
|
2538
|
+
"-c",
|
|
2539
|
+
"copy",
|
|
2540
|
+
"-movflags",
|
|
2541
|
+
"+faststart",
|
|
2542
|
+
"-avoid_negative_ts",
|
|
2543
|
+
"make_zero",
|
|
2544
|
+
"-use_editlist",
|
|
2545
|
+
"0",
|
|
2546
|
+
outputPath
|
|
2547
|
+
]);
|
|
2548
|
+
if (!result.success) {
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
const output = await io.readFile(outputPath);
|
|
2552
|
+
return output.length > 0 ? output : null;
|
|
2553
|
+
} catch {
|
|
2554
|
+
return null;
|
|
2555
|
+
} finally {
|
|
2556
|
+
if (directory && io) {
|
|
2557
|
+
await io.removeDir(directory).catch(() => {
|
|
2558
|
+
return;
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
async function nodeRuntime() {
|
|
2564
|
+
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
2565
|
+
import("node:child_process"),
|
|
2566
|
+
import("node:fs/promises"),
|
|
2567
|
+
import("node:os"),
|
|
2568
|
+
import("node:path")
|
|
2569
|
+
]);
|
|
2570
|
+
return {
|
|
2571
|
+
makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
|
|
2572
|
+
readFile: async (path) => new Uint8Array(await readFile(path)),
|
|
2573
|
+
removeDir: async (path) => {
|
|
2574
|
+
await rm(path, { force: true, recursive: true });
|
|
2575
|
+
},
|
|
2576
|
+
run: (command, args) => new Promise((resolve2) => {
|
|
2577
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2578
|
+
let stderr = "";
|
|
2579
|
+
child.stderr?.on("data", (chunk) => {
|
|
2580
|
+
if (stderr.length < 4096) {
|
|
2581
|
+
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
child.once("error", (error) => resolve2({ stderr: error.message, success: false }));
|
|
2585
|
+
child.once("close", (code) => resolve2({ stderr, success: code === 0 }));
|
|
2586
|
+
}),
|
|
2587
|
+
writeFile: async (path, value) => {
|
|
2588
|
+
await writeFile(path, value);
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2275
2593
|
// src/remux.ts
|
|
2276
2594
|
function mergeAudioVideo(video, audio) {
|
|
2277
2595
|
const videoSource = analyze(video);
|
|
@@ -2498,7 +2816,7 @@ function buffered(item) {
|
|
|
2498
2816
|
async function download(item, options = {}) {
|
|
2499
2817
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2500
2818
|
if (buffered(item)) {
|
|
2501
|
-
const bytes = await
|
|
2819
|
+
const bytes = await itemBytes2(net, item);
|
|
2502
2820
|
return new Response(toArrayBuffer(bytes), { headers: { "content-length": String(bytes.length), "content-type": item.mime } });
|
|
2503
2821
|
}
|
|
2504
2822
|
const response = await net(item.url, { headers: item.headers });
|
|
@@ -2507,9 +2825,32 @@ async function download(item, options = {}) {
|
|
|
2507
2825
|
}
|
|
2508
2826
|
return response;
|
|
2509
2827
|
}
|
|
2828
|
+
async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
|
|
2829
|
+
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
2830
|
+
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
2831
|
+
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
2832
|
+
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2833
|
+
const response = await net(url, { headers });
|
|
2834
|
+
if (!response.ok || !response.body) {
|
|
2835
|
+
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
2836
|
+
}
|
|
2837
|
+
const blob = await response.blob();
|
|
2838
|
+
if (!modern || optionsOrHeaders.remux !== true) {
|
|
2839
|
+
return blob;
|
|
2840
|
+
}
|
|
2841
|
+
const remuxed = await remuxMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath);
|
|
2842
|
+
return remuxed ? new Blob([toArrayBuffer(remuxed)], { type: blob.type || "video/mp4" }) : blob;
|
|
2843
|
+
}
|
|
2844
|
+
function isDownloadBlobOptions(value) {
|
|
2845
|
+
if (value instanceof Headers || Array.isArray(value)) {
|
|
2846
|
+
return false;
|
|
2847
|
+
}
|
|
2848
|
+
const candidate = value;
|
|
2849
|
+
return "headers" in candidate && typeof candidate.headers !== "string" || typeof candidate.remux === "boolean" || typeof candidate.ffmpegPath === "string" || typeof candidate.fetch === "function";
|
|
2850
|
+
}
|
|
2510
2851
|
async function archive(result, options = {}) {
|
|
2511
2852
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2512
|
-
const files = await Promise.all(result.items.map(async (item) => ({ data: await
|
|
2853
|
+
const files = await Promise.all(result.items.map(async (item) => ({ data: await itemBytes2(net, item), name: item.filename })));
|
|
2513
2854
|
return { bytes: zip(files), filename: result.archiveFilename, mime: "application/zip" };
|
|
2514
2855
|
}
|
|
2515
2856
|
async function toResponse(result, options = {}) {
|
|
@@ -2521,7 +2862,7 @@ async function toResponse(result, options = {}) {
|
|
|
2521
2862
|
async function singleResponse(item, options) {
|
|
2522
2863
|
if (buffered(item)) {
|
|
2523
2864
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2524
|
-
const bytes = await
|
|
2865
|
+
const bytes = await itemBytes2(net, item);
|
|
2525
2866
|
return new Response(toArrayBuffer(bytes), {
|
|
2526
2867
|
headers: {
|
|
2527
2868
|
"content-disposition": `attachment; filename="${item.filename}"`,
|
|
@@ -2562,7 +2903,7 @@ async function archiveResponse(result, options) {
|
|
|
2562
2903
|
}
|
|
2563
2904
|
});
|
|
2564
2905
|
}
|
|
2565
|
-
async function
|
|
2906
|
+
async function itemBytes2(net, item) {
|
|
2566
2907
|
if (item.hls) {
|
|
2567
2908
|
const video = await assembleHls(net, item.url, item.headers);
|
|
2568
2909
|
if (item.audio) {
|
|
@@ -2595,6 +2936,7 @@ function toArrayBuffer(bytes) {
|
|
|
2595
2936
|
export {
|
|
2596
2937
|
toResponse,
|
|
2597
2938
|
postfetch,
|
|
2939
|
+
downloadBlob,
|
|
2598
2940
|
download,
|
|
2599
2941
|
detect,
|
|
2600
2942
|
archive,
|
package/dist/internal.d.ts
CHANGED
|
@@ -99,6 +99,17 @@ export type TiktokExtra = {
|
|
|
99
99
|
export type TwitterExtra = {
|
|
100
100
|
/** BCP 47 language tag detected for the tweet. */
|
|
101
101
|
lang?: string;
|
|
102
|
+
/** The post quoted by this post, when X includes it in the resolved payload. */
|
|
103
|
+
quotedTweet?: TwitterQuotedTweet;
|
|
104
|
+
};
|
|
105
|
+
/** A quoted X post and its normalized metadata. */
|
|
106
|
+
export type TwitterQuotedTweet = {
|
|
107
|
+
/** Status id of the quoted post. */
|
|
108
|
+
id: string;
|
|
109
|
+
/** Metadata belonging to the quoted post rather than the outer post. */
|
|
110
|
+
metadata: PostMetadata & {
|
|
111
|
+
extra?: TwitterExtra;
|
|
112
|
+
};
|
|
102
113
|
};
|
|
103
114
|
/** Instagram-specific metadata. */
|
|
104
115
|
export type InstagramExtra = {
|
|
@@ -158,6 +169,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
|
|
|
158
169
|
export type ResolveContext = {
|
|
159
170
|
net: Net;
|
|
160
171
|
preferredWidth: number;
|
|
172
|
+
/** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
|
|
173
|
+
tryMaxBytes?: number;
|
|
161
174
|
url: string;
|
|
162
175
|
};
|
|
163
176
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type CommandResult = {
|
|
2
|
+
stderr: string;
|
|
3
|
+
success: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type Mp4RemuxRuntime = {
|
|
6
|
+
makeTempDir(prefix: string): Promise<string>;
|
|
7
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
8
|
+
removeDir(path: string): Promise<void>;
|
|
9
|
+
run(command: string, args: string[]): Promise<CommandResult>;
|
|
10
|
+
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Normalize an MP4 with the same stream-copy remux used by UMMR. Encoded media
|
|
14
|
+
* is left untouched; FFmpeg only rebuilds the container and timing tables.
|
|
15
|
+
* Returns `null` when FFmpeg is unavailable or the input cannot be remuxed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function remuxMp4(bytes: Uint8Array, ffmpegPath?: string, runtime?: Mp4RemuxRuntime): Promise<Uint8Array | null>;
|
|
18
|
+
export {};
|
package/dist/postfetch.d.ts
CHANGED
|
@@ -5,14 +5,24 @@ export type PostfetchOptions = {
|
|
|
5
5
|
fetch?: typeof fetch;
|
|
6
6
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
7
7
|
preferredWidth?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Soft file-size cap in bytes. The normal rendition is resolved and probed
|
|
10
|
+
* with HEAD; when it exceeds the cap, a smaller available rendition is
|
|
11
|
+
* returned. X probes every available MP4 variant and picks the highest-quality
|
|
12
|
+
* complete result under the cap, or its smallest variants when none fit. For
|
|
13
|
+
* other platforms, if size discovery or a smaller rendition is unavailable,
|
|
14
|
+
* the normal result is returned unchanged.
|
|
15
|
+
*/
|
|
16
|
+
tryMaxBytes?: number;
|
|
8
17
|
};
|
|
9
18
|
/**
|
|
10
19
|
* Resolve a public social post URL to its media.
|
|
11
20
|
*
|
|
12
21
|
* Detects the platform from the URL host, then returns the post's media as typed
|
|
13
22
|
* {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
|
|
14
|
-
* performs no side effects beyond the lookup; use {@link download},
|
|
15
|
-
* or {@link toResponse} to turn the result
|
|
23
|
+
* performs no side effects beyond the lookup; use {@link download},
|
|
24
|
+
* {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
|
|
25
|
+
* into bytes, a `Blob` or a `Response`.
|
|
16
26
|
*
|
|
17
27
|
* @param url A post URL from a supported platform.
|
|
18
28
|
* @param options See {@link PostfetchOptions}.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|