@postfetch/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files. Part of [postfetch](https://github.com/chelokot/postfetch).
4
4
 
5
5
  ```ts
6
- import { postfetch, download, archive, toResponse, PostfetchError } from "@postfetch/core";
6
+ import { postfetch, download, downloadBlob, archive, toResponse, PostfetchError } from "@postfetch/core";
7
7
 
8
8
  const result = await postfetch("https://vt.tiktok.com/ZSxpHvCUM/");
9
9
  for (const item of result.items) {
@@ -11,9 +11,13 @@ for (const item of result.items) {
11
11
  }
12
12
  ```
13
13
 
14
- `postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `archive` and `toResponse` turn the result into bytes or an HTTP `Response`.
14
+ `postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `downloadBlob`, `archive` and `toResponse` materialize the result.
15
15
 
16
- - `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number }`. Inject `fetch` to unit-test resolvers offline.
16
+ `downloadBlob(url, headers?, options?)` materializes an already-resolved media URL for
17
+ clients that need an uploadable `Blob`. Pass the resolved item's headers for
18
+ protected CDN URLs: `downloadBlob(item.url, item.headers)`.
19
+
20
+ - `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap: when the normal media is larger, a smaller available rendition is returned; if its size cannot be discovered or no smaller rendition is available, the normal result is kept. Inject `fetch` to unit-test resolvers offline.
17
21
  - Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
18
22
  - No runtime dependencies, no `ffmpeg` / `yt-dlp`, no cookies.
19
23
 
@@ -30,6 +30,26 @@ export type Archive = {
30
30
  * ```
31
31
  */
32
32
  export declare function download(item: MediaItem, options?: DownloadOptions): Promise<Response>;
33
+ /**
34
+ * Download an already-resolved media URL as a Blob.
35
+ *
36
+ * This is useful for consumers that need to upload media themselves instead of
37
+ * passing its URL to a third party. When the resolved {@link MediaItem} carries
38
+ * required CDN headers, pass them as the second argument.
39
+ *
40
+ * @param url A direct media URL returned by a resolver.
41
+ * @param headers Headers required by the resolved media URL.
42
+ * @param options Fetch options.
43
+ * @returns The downloaded media as a Blob.
44
+ *
45
+ * @example Upload a video with FormData
46
+ * ```ts
47
+ * const [media] = (await postfetch(sourceUrl)).items;
48
+ * const blob = await downloadBlob(media.url, media.headers);
49
+ * form.append("video", blob, media.filename);
50
+ * ```
51
+ */
52
+ export declare function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
33
53
  /**
34
54
  * Download every item of a result and zip them in-process (store mode).
35
55
  *
package/dist/index.d.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
6
6
  * (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
7
7
  * Cloudflare Workers and browsers alike. It only resolves the media; the
8
- * {@link download}, {@link archive} and {@link toResponse} helpers turn a result
9
- * into bytes or a `Response`.
8
+ * {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
9
+ * helpers turn a result into bytes, a `Blob`, or a `Response`.
10
10
  *
11
11
  * @example Resolve a reel and save the video
12
12
  * ```ts
@@ -20,6 +20,6 @@
20
20
  * @module
21
21
  */
22
22
  export { postfetch, detect, type PostfetchOptions } from "./postfetch";
23
- export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
23
+ export { download, downloadBlob, archive, toResponse, type Archive, type DownloadOptions, } from "./download";
24
24
  export { PostfetchError } from "./internal";
25
- export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, YoutubeExtra, } from "./internal";
25
+ export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, TwitterQuotedTweet, YoutubeExtra, } from "./internal";
package/dist/index.js CHANGED
@@ -162,13 +162,13 @@ function instagramAppUserAgent() {
162
162
 
163
163
  // src/facebook.ts
164
164
  async function resolveFacebook(input) {
165
- const canonical = await canonicalUrl(input.net, input.url);
165
+ const canonical = await canonicalUrl(input.net, normalizedInputUrl(input.url));
166
166
  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;
167
+ const embed = await embedVideo(input.net, canonical, input.preferredWidth);
168
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
169
169
  const url = embed ?? watch?.url ?? null;
170
170
  if (!url) {
171
- throw new Error("Facebook video not found");
171
+ throw new PostfetchError(404, "Facebook video not found", "notFound");
172
172
  }
173
173
  const item = {
174
174
  filename: filename(`facebook_${id}.mp4`),
@@ -181,6 +181,11 @@ async function resolveFacebook(input) {
181
181
  };
182
182
  return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
183
183
  }
184
+ function normalizedInputUrl(input) {
185
+ const url = asUrl(input);
186
+ const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
187
+ return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
188
+ }
184
189
  function navigationHeaders2() {
185
190
  return {
186
191
  "accept-language": "en-US,en;q=0.9",
@@ -193,25 +198,30 @@ async function canonicalUrl(net, input) {
193
198
  const resolved = asUrl(response.url);
194
199
  return `${resolved.origin}${resolved.pathname}`;
195
200
  }
196
- async function embedVideo(net, canonical) {
201
+ async function embedVideo(net, canonical, preferredWidth) {
197
202
  const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
198
203
  const response = await net(embed, { headers: navigationHeaders2() });
199
204
  if (!response.ok) {
200
205
  return null;
201
206
  }
202
207
  const html = await response.text();
203
- return source(html, "hd_src") ?? source(html, "sd_src");
208
+ return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
204
209
  }
205
- async function watchVideo(net, id) {
210
+ async function watchVideo(net, id, preferredWidth) {
206
211
  const headers = { ...navigationHeaders2(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
207
212
  const response = await net(`https://www.facebook.com/watch/?v=${id}&_rdr`, { headers });
208
213
  if (!response.ok) {
209
214
  return null;
210
215
  }
211
216
  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");
217
+ const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
218
+ const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
219
+ const url = preferredSource(preferredWidth, hd, sd);
213
220
  return url ? { url, metadata: watchMetadata(html) } : null;
214
221
  }
222
+ function preferredSource(preferredWidth, hd, sd) {
223
+ return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
224
+ }
215
225
  function watchMetadata(html) {
216
226
  const owner = html.match(/"name":("(?:\\.|[^"\\])*"),"__isVideoOwner"/);
217
227
  const name = owner ? JSON.parse(owner[1]) : undefined;
@@ -1925,8 +1935,10 @@ async function resolveTwitter(input) {
1925
1935
  if (string(tweet.__typename) === "TweetTombstone") {
1926
1936
  throw new Error("Tweet is unavailable or age-restricted");
1927
1937
  }
1928
- const media = Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails.filter(object) : [];
1929
- const items = media.flatMap((entry, index) => twitterItem(entry, id, index + 1));
1938
+ const items = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
1939
+ const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
1940
+ return media.flatMap((entry, index) => twitterItem(entry, postId2, index + 1));
1941
+ });
1930
1942
  if (items.length === 0) {
1931
1943
  throw new Error("Twitter media not found");
1932
1944
  }
@@ -1934,6 +1946,16 @@ async function resolveTwitter(input) {
1934
1946
  }
1935
1947
  function twitterMetadata(tweet) {
1936
1948
  const user = object(tweet.user) ? tweet.user : null;
1949
+ const extra = {};
1950
+ const lang = string(tweet.lang);
1951
+ if (lang) {
1952
+ extra.lang = lang;
1953
+ }
1954
+ const quotedTweet = object(tweet.quoted_tweet) ? tweet.quoted_tweet : null;
1955
+ const quotedId = quotedTweet ? string(quotedTweet.id_str) : null;
1956
+ if (quotedTweet && quotedId) {
1957
+ extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
1958
+ }
1937
1959
  return {
1938
1960
  text: string(tweet.text) ?? string(tweet.full_text) ?? undefined,
1939
1961
  author: user ? {
@@ -1947,9 +1969,26 @@ function twitterMetadata(tweet) {
1947
1969
  shareCount: count(tweet.retweet_count),
1948
1970
  viewCount: count(tweet.view_count),
1949
1971
  nsfw: bool(tweet.possibly_sensitive),
1950
- extra: { lang: string(tweet.lang) ?? undefined }
1972
+ extra
1951
1973
  };
1952
1974
  }
1975
+ function twitterTweets(tweet, rootId) {
1976
+ const tweets = [];
1977
+ const seen = new Set;
1978
+ let current = tweet;
1979
+ let fallbackId = rootId;
1980
+ while (current) {
1981
+ const id = string(current.id_str) ?? fallbackId;
1982
+ if (!id || seen.has(id)) {
1983
+ break;
1984
+ }
1985
+ tweets.push({ id, tweet: current });
1986
+ seen.add(id);
1987
+ current = object(current.quoted_tweet) ? current.quoted_tweet : null;
1988
+ fallbackId = null;
1989
+ }
1990
+ return tweets;
1991
+ }
1953
1992
  async function syndication(net, id) {
1954
1993
  const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${syndicationToken(id)}`;
1955
1994
  const response = await net(url, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
@@ -2215,12 +2254,19 @@ async function postfetch(url, options = {}) {
2215
2254
  if (trimmed.length === 0) {
2216
2255
  throw new PostfetchError(400, "url is required");
2217
2256
  }
2257
+ if (options.tryMaxBytes !== undefined && (!Number.isSafeInteger(options.tryMaxBytes) || options.tryMaxBytes <= 0)) {
2258
+ throw new PostfetchError(400, "tryMaxBytes must be a positive integer");
2259
+ }
2218
2260
  const context = {
2219
2261
  net: createNet(options.fetch ?? globalThis.fetch),
2220
2262
  preferredWidth: options.preferredWidth ?? 720,
2221
2263
  url: trimmed
2222
2264
  };
2223
- switch (detect(trimmed)) {
2265
+ const result = await resolve(context);
2266
+ return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
2267
+ }
2268
+ async function resolve(context) {
2269
+ switch (detect(context.url)) {
2224
2270
  case "facebook":
2225
2271
  return resolveFacebook(context);
2226
2272
  case "instagram":
@@ -2241,6 +2287,58 @@ async function postfetch(url, options = {}) {
2241
2287
  return resolveYoutube(context);
2242
2288
  }
2243
2289
  }
2290
+ async function trySmaller(context, result, maxBytes) {
2291
+ const originalBytes = await resultBytes(context, result);
2292
+ if (originalBytes === null || originalBytes <= maxBytes) {
2293
+ return result;
2294
+ }
2295
+ try {
2296
+ const smaller = await resolve({ ...context, preferredWidth: 1 });
2297
+ if (sameMedia(result, smaller)) {
2298
+ return result;
2299
+ }
2300
+ const smallerBytes = await resultBytes(context, smaller);
2301
+ return smallerBytes !== null && smallerBytes < originalBytes ? smaller : result;
2302
+ } catch {
2303
+ return result;
2304
+ }
2305
+ }
2306
+ async function resultBytes(context, result) {
2307
+ const sizes = await Promise.all(result.items.map((item) => itemBytes(context, item)));
2308
+ if (sizes.some((size) => size === null)) {
2309
+ return null;
2310
+ }
2311
+ return sizes.reduce((total, size) => total + (size ?? 0), 0);
2312
+ }
2313
+ async function itemBytes(context, item) {
2314
+ if (item.hls) {
2315
+ return null;
2316
+ }
2317
+ const [video, audio] = await Promise.all([
2318
+ contentLength(context, item.url, item.headers),
2319
+ item.audio ? contentLength(context, item.audio.url, item.audio.headers) : Promise.resolve(0)
2320
+ ]);
2321
+ return video === null || audio === null ? null : video + audio;
2322
+ }
2323
+ async function contentLength(context, url, headers) {
2324
+ try {
2325
+ const response = await context.net(url, { headers, method: "HEAD" }, 1);
2326
+ const raw = response.ok ? response.headers.get("content-length") : null;
2327
+ if (!raw || !/^\d+$/.test(raw)) {
2328
+ return null;
2329
+ }
2330
+ const bytes = Number(raw);
2331
+ return Number.isSafeInteger(bytes) ? bytes : null;
2332
+ } catch {
2333
+ return null;
2334
+ }
2335
+ }
2336
+ function sameMedia(left, right) {
2337
+ return left.items.length === right.items.length && left.items.every((item, index) => {
2338
+ const other = right.items[index];
2339
+ return item.url === other?.url && item.audio?.url === other.audio?.url;
2340
+ });
2341
+ }
2244
2342
  function detect(url) {
2245
2343
  const host = asUrl(url).hostname;
2246
2344
  if (host.includes("tiktok.com")) {
@@ -2498,7 +2596,7 @@ function buffered(item) {
2498
2596
  async function download(item, options = {}) {
2499
2597
  const net = createNet(options.fetch ?? globalThis.fetch);
2500
2598
  if (buffered(item)) {
2501
- const bytes = await itemBytes(net, item);
2599
+ const bytes = await itemBytes2(net, item);
2502
2600
  return new Response(toArrayBuffer(bytes), { headers: { "content-length": String(bytes.length), "content-type": item.mime } });
2503
2601
  }
2504
2602
  const response = await net(item.url, { headers: item.headers });
@@ -2507,9 +2605,17 @@ async function download(item, options = {}) {
2507
2605
  }
2508
2606
  return response;
2509
2607
  }
2608
+ async function downloadBlob(url, headers = {}, options = {}) {
2609
+ const net = createNet(options.fetch ?? globalThis.fetch);
2610
+ const response = await net(url, { headers });
2611
+ if (!response.ok || !response.body) {
2612
+ throw new PostfetchError(502, `download failed: ${response.status}`);
2613
+ }
2614
+ return response.blob();
2615
+ }
2510
2616
  async function archive(result, options = {}) {
2511
2617
  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 })));
2618
+ const files = await Promise.all(result.items.map(async (item) => ({ data: await itemBytes2(net, item), name: item.filename })));
2513
2619
  return { bytes: zip(files), filename: result.archiveFilename, mime: "application/zip" };
2514
2620
  }
2515
2621
  async function toResponse(result, options = {}) {
@@ -2521,7 +2627,7 @@ async function toResponse(result, options = {}) {
2521
2627
  async function singleResponse(item, options) {
2522
2628
  if (buffered(item)) {
2523
2629
  const net = createNet(options.fetch ?? globalThis.fetch);
2524
- const bytes = await itemBytes(net, item);
2630
+ const bytes = await itemBytes2(net, item);
2525
2631
  return new Response(toArrayBuffer(bytes), {
2526
2632
  headers: {
2527
2633
  "content-disposition": `attachment; filename="${item.filename}"`,
@@ -2562,7 +2668,7 @@ async function archiveResponse(result, options) {
2562
2668
  }
2563
2669
  });
2564
2670
  }
2565
- async function itemBytes(net, item) {
2671
+ async function itemBytes2(net, item) {
2566
2672
  if (item.hls) {
2567
2673
  const video = await assembleHls(net, item.url, item.headers);
2568
2674
  if (item.audio) {
@@ -2595,6 +2701,7 @@ function toArrayBuffer(bytes) {
2595
2701
  export {
2596
2702
  toResponse,
2597
2703
  postfetch,
2704
+ downloadBlob,
2598
2705
  download,
2599
2706
  detect,
2600
2707
  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 = {
@@ -5,14 +5,22 @@ 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. If size discovery or the smaller rendition is unavailable, the
12
+ * normal result is returned unchanged.
13
+ */
14
+ tryMaxBytes?: number;
8
15
  };
9
16
  /**
10
17
  * Resolve a public social post URL to its media.
11
18
  *
12
19
  * Detects the platform from the URL host, then returns the post's media as typed
13
20
  * {@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`.
21
+ * performs no side effects beyond the lookup; use {@link download},
22
+ * {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
23
+ * into bytes, a `Blob` or a `Response`.
16
24
  *
17
25
  * @param url A post URL from a supported platform.
18
26
  * @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.7.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",
package/src/download.ts CHANGED
@@ -54,6 +54,38 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
54
54
  return response;
55
55
  }
56
56
 
57
+ /**
58
+ * Download an already-resolved media URL as a Blob.
59
+ *
60
+ * This is useful for consumers that need to upload media themselves instead of
61
+ * passing its URL to a third party. When the resolved {@link MediaItem} carries
62
+ * required CDN headers, pass them as the second argument.
63
+ *
64
+ * @param url A direct media URL returned by a resolver.
65
+ * @param headers Headers required by the resolved media URL.
66
+ * @param options Fetch options.
67
+ * @returns The downloaded media as a Blob.
68
+ *
69
+ * @example Upload a video with FormData
70
+ * ```ts
71
+ * const [media] = (await postfetch(sourceUrl)).items;
72
+ * const blob = await downloadBlob(media.url, media.headers);
73
+ * form.append("video", blob, media.filename);
74
+ * ```
75
+ */
76
+ export async function downloadBlob(
77
+ url: string,
78
+ headers: HeadersInit = {},
79
+ options: DownloadOptions = {},
80
+ ): Promise<Blob> {
81
+ const net = createNet(options.fetch ?? globalThis.fetch);
82
+ const response = await net(url, { headers });
83
+ if (!response.ok || !response.body) {
84
+ throw new PostfetchError(502, `download failed: ${response.status}`);
85
+ }
86
+ return response.blob();
87
+ }
88
+
57
89
  /**
58
90
  * Download every item of a result and zip them in-process (store mode).
59
91
  *
package/src/facebook.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { asUrl, count, filename, isoFromEpochSeconds, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
1
+ import { asUrl, count, filename, isoFromEpochSeconds, PostfetchError, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
2
2
  import { browserUserAgent } from "./fingerprint";
3
3
 
4
4
  export async function resolveFacebook(input: ResolveContext): Promise<PostfetchResult> {
5
- const canonical = await canonicalUrl(input.net, input.url);
5
+ const canonical = await canonicalUrl(input.net, normalizedInputUrl(input.url));
6
6
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
7
7
  // The embed player only exposes /share/v/ videos; reels (and anything it
8
8
  // misses) come from the watch page, which the &_rdr flag serves logged-out.
9
- const embed = await embedVideo(input.net, canonical);
10
- const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id) : null;
9
+ const embed = await embedVideo(input.net, canonical, input.preferredWidth);
10
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
11
11
  const url = embed ?? watch?.url ?? null;
12
12
  if (!url) {
13
- throw new Error("Facebook video not found");
13
+ throw new PostfetchError(404, "Facebook video not found", "notFound");
14
14
  }
15
15
  const item: MediaItem = {
16
16
  filename: filename(`facebook_${id}.mp4`),
@@ -24,6 +24,16 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
24
24
  return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
25
25
  }
26
26
 
27
+ // Facebook share links are opaque shortlinks. Messaging apps sometimes append
28
+ // selected text to the copied URL as another path segment; Facebook returns 404
29
+ // for that form even though the share token is still intact. Strip everything
30
+ // after the token before following the redirect.
31
+ function normalizedInputUrl(input: string): string {
32
+ const url = asUrl(input);
33
+ const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
34
+ return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
35
+ }
36
+
27
37
  function navigationHeaders(): Record<string, string> {
28
38
  return {
29
39
  "accept-language": "en-US,en;q=0.9",
@@ -43,19 +53,19 @@ async function canonicalUrl(net: Net, input: string): Promise<string> {
43
53
 
44
54
  // The logged-out watch page is fingerprint-walled, but the public embed player
45
55
  // (plugins/video.php) still exposes the progressive hd_src/sd_src URLs.
46
- async function embedVideo(net: Net, canonical: string): Promise<string | null> {
56
+ async function embedVideo(net: Net, canonical: string, preferredWidth: number): Promise<string | null> {
47
57
  const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
48
58
  const response = await net(embed, { headers: navigationHeaders() });
49
59
  if (!response.ok) {
50
60
  return null;
51
61
  }
52
62
  const html = await response.text();
53
- return source(html, "hd_src") ?? source(html, "sd_src");
63
+ return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
54
64
  }
55
65
 
56
66
  // The logged-out watch page (with &_rdr) embeds the progressive video URLs in
57
67
  // its ScheduledServerJS blocks; these are the same fields yt-dlp reads.
58
- async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata: PostMetadata } | null> {
68
+ async function watchVideo(net: Net, id: string, preferredWidth: number): Promise<{ url: string; metadata: PostMetadata } | null> {
59
69
  // Without the document Accept header Facebook serves a JS shell without the
60
70
  // media JSON, so ask for HTML explicitly here.
61
71
  const headers = { ...navigationHeaders(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
@@ -64,14 +74,18 @@ async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata
64
74
  return null;
65
75
  }
66
76
  const html = await response.text();
67
- const url =
68
- source(html, "playable_url_quality_hd") ??
69
- source(html, "browser_native_hd_url") ??
70
- source(html, "playable_url") ??
71
- source(html, "browser_native_sd_url");
77
+ const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
78
+ const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
79
+ const url = preferredSource(preferredWidth, hd, sd);
72
80
  return url ? { url, metadata: watchMetadata(html) } : null;
73
81
  }
74
82
 
83
+ // Facebook labels its two progressive renditions as 720p HD and 360p SD. Pick
84
+ // the one closest to the requested width, preferring HD on the 540px midpoint.
85
+ function preferredSource(preferredWidth: number, hd: string | null, sd: string | null): string | null {
86
+ return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
87
+ }
88
+
75
89
  // Only the fields that can be read unambiguously from the flat page: the
76
90
  // caption (creation_story) and the reaction/comment/share counts appear in
77
91
  // shapes shared with related videos and comment threads, so they are skipped
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
6
6
  * (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
7
7
  * Cloudflare Workers and browsers alike. It only resolves the media; the
8
- * {@link download}, {@link archive} and {@link toResponse} helpers turn a result
9
- * into bytes or a `Response`.
8
+ * {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
9
+ * helpers turn a result into bytes, a `Blob`, or a `Response`.
10
10
  *
11
11
  * @example Resolve a reel and save the video
12
12
  * ```ts
@@ -20,7 +20,14 @@
20
20
  * @module
21
21
  */
22
22
  export { postfetch, detect, type PostfetchOptions } from "./postfetch";
23
- export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
23
+ export {
24
+ download,
25
+ downloadBlob,
26
+ archive,
27
+ toResponse,
28
+ type Archive,
29
+ type DownloadOptions,
30
+ } from "./download";
24
31
  export { PostfetchError } from "./internal";
25
32
  export type {
26
33
  InstagramExtra,
@@ -37,5 +44,6 @@ export type {
37
44
  SoundcloudExtra,
38
45
  TiktokExtra,
39
46
  TwitterExtra,
47
+ TwitterQuotedTweet,
40
48
  YoutubeExtra,
41
49
  } from "./internal";
package/src/internal.ts CHANGED
@@ -103,6 +103,16 @@ export type TiktokExtra = {
103
103
  export type TwitterExtra = {
104
104
  /** BCP 47 language tag detected for the tweet. */
105
105
  lang?: string;
106
+ /** The post quoted by this post, when X includes it in the resolved payload. */
107
+ quotedTweet?: TwitterQuotedTweet;
108
+ };
109
+
110
+ /** A quoted X post and its normalized metadata. */
111
+ export type TwitterQuotedTweet = {
112
+ /** Status id of the quoted post. */
113
+ id: string;
114
+ /** Metadata belonging to the quoted post rather than the outer post. */
115
+ metadata: PostMetadata & { extra?: TwitterExtra };
106
116
  };
107
117
 
108
118
  /** Instagram-specific metadata. */
package/src/postfetch.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { asUrl, createNet, PostfetchError, type Platform, type PostfetchResult } from "./internal";
1
+ import { asUrl, createNet, PostfetchError, type MediaItem, type Platform, type PostfetchResult, type ResolveContext } from "./internal";
2
2
  import { resolveFacebook } from "./facebook";
3
3
  import { resolveInstagram } from "./instagram";
4
4
  import { resolveLinkedin } from "./linkedin";
@@ -15,6 +15,13 @@ export type PostfetchOptions = {
15
15
  fetch?: typeof fetch;
16
16
  /** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
17
17
  preferredWidth?: number;
18
+ /**
19
+ * Soft file-size cap in bytes. The normal rendition is resolved and probed
20
+ * with HEAD; when it exceeds the cap, a smaller available rendition is
21
+ * returned. If size discovery or the smaller rendition is unavailable, the
22
+ * normal result is returned unchanged.
23
+ */
24
+ tryMaxBytes?: number;
18
25
  };
19
26
 
20
27
  /**
@@ -22,8 +29,9 @@ export type PostfetchOptions = {
22
29
  *
23
30
  * Detects the platform from the URL host, then returns the post's media as typed
24
31
  * {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
25
- * performs no side effects beyond the lookup; use {@link download}, {@link archive}
26
- * or {@link toResponse} to turn the result into bytes or a `Response`.
32
+ * performs no side effects beyond the lookup; use {@link download},
33
+ * {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
34
+ * into bytes, a `Blob` or a `Response`.
27
35
  *
28
36
  * @param url A post URL from a supported platform.
29
37
  * @param options See {@link PostfetchOptions}.
@@ -43,12 +51,20 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
43
51
  if (trimmed.length === 0) {
44
52
  throw new PostfetchError(400, "url is required");
45
53
  }
46
- const context = {
54
+ if (options.tryMaxBytes !== undefined && (!Number.isSafeInteger(options.tryMaxBytes) || options.tryMaxBytes <= 0)) {
55
+ throw new PostfetchError(400, "tryMaxBytes must be a positive integer");
56
+ }
57
+ const context: ResolveContext = {
47
58
  net: createNet(options.fetch ?? globalThis.fetch),
48
59
  preferredWidth: options.preferredWidth ?? 720,
49
60
  url: trimmed,
50
61
  };
51
- switch (detect(trimmed)) {
62
+ const result = await resolve(context);
63
+ return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
64
+ }
65
+
66
+ async function resolve(context: ResolveContext): Promise<PostfetchResult> {
67
+ switch (detect(context.url)) {
52
68
  case "facebook":
53
69
  return resolveFacebook(context);
54
70
  case "instagram":
@@ -70,6 +86,65 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
70
86
  }
71
87
  }
72
88
 
89
+ async function trySmaller(context: ResolveContext, result: PostfetchResult, maxBytes: number): Promise<PostfetchResult> {
90
+ const originalBytes = await resultBytes(context, result);
91
+ if (originalBytes === null || originalBytes <= maxBytes) {
92
+ return result;
93
+ }
94
+ try {
95
+ // Width-aware resolvers interpret 1px as "pick the smallest available".
96
+ // Resolvers without a smaller rendition return the same media unchanged.
97
+ const smaller = await resolve({ ...context, preferredWidth: 1 });
98
+ if (sameMedia(result, smaller)) {
99
+ return result;
100
+ }
101
+ const smallerBytes = await resultBytes(context, smaller);
102
+ return smallerBytes !== null && smallerBytes < originalBytes ? smaller : result;
103
+ } catch {
104
+ return result;
105
+ }
106
+ }
107
+
108
+ async function resultBytes(context: ResolveContext, result: PostfetchResult): Promise<number | null> {
109
+ const sizes = await Promise.all(result.items.map((item) => itemBytes(context, item)));
110
+ if (sizes.some((size) => size === null)) {
111
+ return null;
112
+ }
113
+ return sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
114
+ }
115
+
116
+ async function itemBytes(context: ResolveContext, item: MediaItem): Promise<number | null> {
117
+ if (item.hls) {
118
+ return null;
119
+ }
120
+ const [video, audio] = await Promise.all([
121
+ contentLength(context, item.url, item.headers),
122
+ item.audio ? contentLength(context, item.audio.url, item.audio.headers) : Promise.resolve(0),
123
+ ]);
124
+ return video === null || audio === null ? null : video + audio;
125
+ }
126
+
127
+ async function contentLength(context: ResolveContext, url: string, headers: HeadersInit): Promise<number | null> {
128
+ try {
129
+ const response = await context.net(url, { headers, method: "HEAD" }, 1);
130
+ const raw = response.ok ? response.headers.get("content-length") : null;
131
+ if (!raw || !/^\d+$/.test(raw)) {
132
+ return null;
133
+ }
134
+ const bytes = Number(raw);
135
+ return Number.isSafeInteger(bytes) ? bytes : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function sameMedia(left: PostfetchResult, right: PostfetchResult): boolean {
142
+ return left.items.length === right.items.length && left.items.every((item, index) => {
143
+ const other = right.items[index];
144
+ return item.url === other?.url && item.audio?.url === other.audio?.url;
145
+ });
146
+ }
147
+
73
148
  /**
74
149
  * Detect which platform a URL belongs to, from its host.
75
150
  *
package/src/twitter.ts CHANGED
@@ -26,8 +26,10 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
26
26
  if (string(tweet.__typename) === "TweetTombstone") {
27
27
  throw new Error("Tweet is unavailable or age-restricted");
28
28
  }
29
- const media = Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails.filter(object) : [];
30
- const items = media.flatMap((entry, index) => twitterItem(entry, id, index + 1));
29
+ const items = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
30
+ const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
31
+ return media.flatMap((entry, index) => twitterItem(entry, postId, index + 1));
32
+ });
31
33
  if (items.length === 0) {
32
34
  throw new Error("Twitter media not found");
33
35
  }
@@ -36,6 +38,16 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
36
38
 
37
39
  export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterExtra } {
38
40
  const user = object(tweet.user) ? tweet.user : null;
41
+ const extra: TwitterExtra = {};
42
+ const lang = string(tweet.lang);
43
+ if (lang) {
44
+ extra.lang = lang;
45
+ }
46
+ const quotedTweet = object(tweet.quoted_tweet) ? tweet.quoted_tweet : null;
47
+ const quotedId = quotedTweet ? string(quotedTweet.id_str) : null;
48
+ if (quotedTweet && quotedId) {
49
+ extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
50
+ }
39
51
  return {
40
52
  text: (string(tweet.text) ?? string(tweet.full_text)) ?? undefined,
41
53
  author: user
@@ -51,10 +63,28 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
51
63
  shareCount: count(tweet.retweet_count),
52
64
  viewCount: count(tweet.view_count),
53
65
  nsfw: bool(tweet.possibly_sensitive),
54
- extra: { lang: string(tweet.lang) ?? undefined },
66
+ extra,
55
67
  };
56
68
  }
57
69
 
70
+ function twitterTweets(tweet: Json, rootId: string): Array<{ id: string; tweet: Json }> {
71
+ const tweets: Array<{ id: string; tweet: Json }> = [];
72
+ const seen = new Set<string>();
73
+ let current: Json | null = tweet;
74
+ let fallbackId: string | null = rootId;
75
+ while (current) {
76
+ const id = string(current.id_str) ?? fallbackId;
77
+ if (!id || seen.has(id)) {
78
+ break;
79
+ }
80
+ tweets.push({ id, tweet: current });
81
+ seen.add(id);
82
+ current = object(current.quoted_tweet) ? current.quoted_tweet : null;
83
+ fallbackId = null;
84
+ }
85
+ return tweets;
86
+ }
87
+
58
88
  // The public syndication endpoint returns tweet media (video variants + photos)
59
89
  // without a guest token or bearer; the token is a non-validated cache key.
60
90
  async function syndication(net: Net, id: string): Promise<Json> {