@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/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 canonical = await canonicalUrl(input.net, input.url);
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 embed = await embedVideo(input.net, canonical);
168
- const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id) : null;
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
- throw new Error("Facebook video not found");
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 { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
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 canonicalUrl(net, input) {
210
+ async function canonicalPage(net, input) {
192
211
  const response = await net(input, { headers: navigationHeaders2() });
193
- const resolved = asUrl(response.url);
194
- return `${resolved.origin}${resolved.pathname}`;
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") ?? source(html, "sd_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 url = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url") ?? source(html, "playable_url") ?? source(html, "browser_native_sd_url");
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(decodeHtml(value));
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(decodeHtml(encoded));
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 decodeHtml(value) {
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 canonicalUrl2(input.net, input.url);
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 canonicalUrl2(net, input) {
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 media = Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails.filter(object) : [];
1929
- const items = media.flatMap((entry, index) => twitterItem(entry, id, index + 1));
1930
- if (items.length === 0) {
1931
- throw new Error("Twitter media not found");
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: { lang: string(tweet.lang) ?? undefined }
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 twitterItem(entry, id, index) {
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
- const url = bestVariant(entry);
1973
- return url ? [{ filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }] : [];
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 bestVariant(entry) {
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 best = variants.filter((variant) => string(variant.content_type) === "video/mp4").reduce((current, variant) => {
1982
- const bitrate = number(variant.bitrate) ?? 0;
1983
- const currentBitrate = current ? number(current.bitrate) ?? 0 : -1;
1984
- return bitrate > currentBitrate ? variant : current;
1985
- }, null);
1986
- return best ? string(best.url) : null;
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
- switch (detect(trimmed)) {
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 itemBytes(net, item);
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 itemBytes(net, item), name: item.filename })));
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 itemBytes(net, item);
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 itemBytes(net, item) {
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,
@@ -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 {};
@@ -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}, {@link archive}
15
- * or {@link toResponse} to turn the result into bytes or a `Response`.
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.6.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",