@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/src/download.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { assembleHls } from "./hls";
2
2
  import { createNet, PostfetchError, type MediaItem, type Net, type PostfetchResult } from "./internal";
3
+ import { remuxMp4 } from "./mp4-remux";
3
4
  import { mergeAudioVideo } from "./remux";
4
5
  import { zip } from "./zip";
5
6
 
@@ -15,6 +16,16 @@ export type DownloadOptions = {
15
16
  fetch?: typeof fetch;
16
17
  };
17
18
 
19
+ /** Options for {@link downloadBlob}. */
20
+ export type DownloadBlobOptions = DownloadOptions & {
21
+ /** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
22
+ ffmpegPath?: string;
23
+ /** Headers required by the resolved media URL. */
24
+ headers?: HeadersInit;
25
+ /** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
26
+ remux?: boolean;
27
+ };
28
+
18
29
  /** A zip archive produced by {@link archive}. */
19
30
  export type Archive = {
20
31
  /** The zip bytes (store mode, built in-process). */
@@ -54,6 +65,63 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
54
65
  return response;
55
66
  }
56
67
 
68
+ /**
69
+ * Download an already-resolved media URL as a Blob.
70
+ *
71
+ * This is useful for consumers that need to upload media themselves instead of
72
+ * passing its URL to a third party. When the resolved {@link MediaItem} carries
73
+ * required CDN headers, pass them in the options object. Set `remux: true` to
74
+ * normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
75
+ * remux fails, the original Blob is returned.
76
+ *
77
+ * @param url A direct media URL returned by a resolver.
78
+ * @param options Headers, fetch implementation and opt-in remux behavior.
79
+ * @returns The downloaded media as a Blob.
80
+ *
81
+ * @example Upload a video with FormData
82
+ * ```ts
83
+ * const [media] = (await postfetch(sourceUrl)).items;
84
+ * const blob = await downloadBlob(media.url, { headers: media.headers, remux: true });
85
+ * form.append("video", blob, media.filename);
86
+ * ```
87
+ */
88
+ export async function downloadBlob(url: string, options?: DownloadBlobOptions): Promise<Blob>;
89
+ /** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
90
+ export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
91
+ export async function downloadBlob(
92
+ url: string,
93
+ optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
94
+ legacyOptions: DownloadOptions = {},
95
+ ): Promise<Blob> {
96
+ const modern = isDownloadBlobOptions(optionsOrHeaders);
97
+ const options = modern ? optionsOrHeaders : legacyOptions;
98
+ const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
99
+ const net = createNet(options.fetch ?? globalThis.fetch);
100
+ const response = await net(url, { headers });
101
+ if (!response.ok || !response.body) {
102
+ throw new PostfetchError(502, `download failed: ${response.status}`);
103
+ }
104
+ const blob = await response.blob();
105
+ if (!modern || optionsOrHeaders.remux !== true) {
106
+ return blob;
107
+ }
108
+ const remuxed = await remuxMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath);
109
+ return remuxed ? new Blob([toArrayBuffer(remuxed)], { type: blob.type || "video/mp4" }) : blob;
110
+ }
111
+
112
+ function isDownloadBlobOptions(value: DownloadBlobOptions | HeadersInit): value is DownloadBlobOptions {
113
+ if (value instanceof Headers || Array.isArray(value)) {
114
+ return false;
115
+ }
116
+ const candidate = value as Record<string, unknown>;
117
+ return (
118
+ ("headers" in candidate && typeof candidate.headers !== "string") ||
119
+ typeof candidate.remux === "boolean" ||
120
+ typeof candidate.ffmpegPath === "string" ||
121
+ typeof candidate.fetch === "function"
122
+ );
123
+ }
124
+
57
125
  /**
58
126
  * Download every item of a result and zip them in-process (store mode).
59
127
  *
package/src/facebook.ts CHANGED
@@ -1,16 +1,24 @@
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 page = await canonicalPage(input.net, normalizedInputUrl(input.url));
6
+ const canonical = page.url;
6
7
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
8
+ const pageMetadata = facebookMetadata(page.html);
7
9
  // The embed player only exposes /share/v/ videos; reels (and anything it
8
10
  // 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;
11
+ const embed = await embedVideo(input.net, canonical, input.preferredWidth);
12
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
11
13
  const url = embed ?? watch?.url ?? null;
12
14
  if (!url) {
13
- throw new Error("Facebook video not found");
15
+ // As with Reddit and X, a public text post is a valid result even when it
16
+ // has no downloadable media. Require post metadata from the canonical page
17
+ // so a missing or login-walled video does not become a false success.
18
+ if (pageMetadata?.text) {
19
+ return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
20
+ }
21
+ throw new PostfetchError(404, "Facebook video not found", "notFound");
14
22
  }
15
23
  const item: MediaItem = {
16
24
  filename: filename(`facebook_${id}.mp4`),
@@ -21,7 +29,23 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
21
29
  platform: "facebook",
22
30
  url,
23
31
  };
24
- return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
32
+ return {
33
+ archiveFilename: filename(`facebook_${id}.zip`),
34
+ id,
35
+ items: [item],
36
+ metadata: mergeMetadata(pageMetadata, watch?.metadata),
37
+ platform: "facebook",
38
+ };
39
+ }
40
+
41
+ // Facebook share links are opaque shortlinks. Messaging apps sometimes append
42
+ // selected text to the copied URL as another path segment; Facebook returns 404
43
+ // for that form even though the share token is still intact. Strip everything
44
+ // after the token before following the redirect.
45
+ function normalizedInputUrl(input: string): string {
46
+ const url = asUrl(input);
47
+ const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
48
+ return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
25
49
  }
26
50
 
27
51
  function navigationHeaders(): Record<string, string> {
@@ -33,29 +57,32 @@ function navigationHeaders(): Record<string, string> {
33
57
  }
34
58
 
35
59
  // Share and fb.watch links redirect to the canonical /reel/<id> or /<page>/videos/<id>
36
- // URL, which the public embed player needs. We only read the resolved location, not the
37
- // (login-walled) page body.
38
- async function canonicalUrl(net: Net, input: string): Promise<string> {
60
+ // URL, which the public embed player needs. Public post pages also carry Open
61
+ // Graph metadata in their logged-out HTML, so retain the body after redirecting.
62
+ async function canonicalPage(net: Net, input: string): Promise<{ html: string; url: string }> {
39
63
  const response = await net(input, { headers: navigationHeaders() });
40
- const resolved = asUrl(response.url);
41
- return `${resolved.origin}${resolved.pathname}`;
64
+ const resolved = asUrl(response.url || input);
65
+ return {
66
+ html: response.ok ? await response.text() : "",
67
+ url: `${resolved.origin}${resolved.pathname}`,
68
+ };
42
69
  }
43
70
 
44
71
  // The logged-out watch page is fingerprint-walled, but the public embed player
45
72
  // (plugins/video.php) still exposes the progressive hd_src/sd_src URLs.
46
- async function embedVideo(net: Net, canonical: string): Promise<string | null> {
73
+ async function embedVideo(net: Net, canonical: string, preferredWidth: number): Promise<string | null> {
47
74
  const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
48
75
  const response = await net(embed, { headers: navigationHeaders() });
49
76
  if (!response.ok) {
50
77
  return null;
51
78
  }
52
79
  const html = await response.text();
53
- return source(html, "hd_src") ?? source(html, "sd_src");
80
+ return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
54
81
  }
55
82
 
56
83
  // The logged-out watch page (with &_rdr) embeds the progressive video URLs in
57
84
  // 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> {
85
+ async function watchVideo(net: Net, id: string, preferredWidth: number): Promise<{ url: string; metadata: PostMetadata } | null> {
59
86
  // Without the document Accept header Facebook serves a JS shell without the
60
87
  // media JSON, so ask for HTML explicitly here.
61
88
  const headers = { ...navigationHeaders(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
@@ -64,14 +91,18 @@ async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata
64
91
  return null;
65
92
  }
66
93
  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");
94
+ const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
95
+ const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
96
+ const url = preferredSource(preferredWidth, hd, sd);
72
97
  return url ? { url, metadata: watchMetadata(html) } : null;
73
98
  }
74
99
 
100
+ // Facebook labels its two progressive renditions as 720p HD and 360p SD. Pick
101
+ // the one closest to the requested width, preferring HD on the 540px midpoint.
102
+ function preferredSource(preferredWidth: number, hd: string | null, sd: string | null): string | null {
103
+ return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
104
+ }
105
+
75
106
  // Only the fields that can be read unambiguously from the flat page: the
76
107
  // caption (creation_story) and the reaction/comment/share counts appear in
77
108
  // shapes shared with related videos and comment threads, so they are skipped
@@ -86,6 +117,76 @@ function watchMetadata(html: string): PostMetadata {
86
117
  };
87
118
  }
88
119
 
120
+ // Public post pages expose their caption and page/profile name as Open Graph
121
+ // metadata even when there is no media player. Facebook truncates long captions
122
+ // there, but it is still enough to identify and represent a text post.
123
+ function facebookMetadata(html: string): PostMetadata | undefined {
124
+ const canonical = metaContent(html, "og:url");
125
+ if (!canonical || !facebookPostUrl(canonical)) {
126
+ return undefined;
127
+ }
128
+ const text = metaContent(html, "og:description") ?? metaContent(html, "description");
129
+ const name = metaContent(html, "og:title");
130
+ if (!text && !name) {
131
+ return undefined;
132
+ }
133
+ return {
134
+ text: text ?? undefined,
135
+ author: name ? { name } : undefined,
136
+ };
137
+ }
138
+
139
+ function facebookPostUrl(input: string): boolean {
140
+ try {
141
+ const url = new URL(input);
142
+ return (
143
+ /\/posts\/(?:[^/]+\/)?[A-Za-z0-9]+\/?$/.test(url.pathname) ||
144
+ /\/share\/p\/[A-Za-z0-9]+\/?$/.test(url.pathname) ||
145
+ ((url.pathname === "/permalink.php" || url.pathname === "/story.php") && Boolean(url.searchParams.get("story_fbid")))
146
+ );
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function metaContent(html: string, key: string): string | null {
153
+ for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
154
+ const tag = match[0];
155
+ if ((attribute(tag, "property") ?? attribute(tag, "name")) !== key) {
156
+ continue;
157
+ }
158
+ const content = attribute(tag, "content");
159
+ return content ? decodeHtml(content) : null;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function attribute(tag: string, name: string): string | null {
165
+ const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "is"));
166
+ return match?.[2] ?? null;
167
+ }
168
+
169
+ function decodeHtml(input: string): string {
170
+ const named: Record<string, string> = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"' };
171
+ return input.replace(/&(#x[\da-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, (entity, code: string) => {
172
+ if (code[0] !== "#") {
173
+ return named[code.toLowerCase()] ?? entity;
174
+ }
175
+ const value = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
176
+ return Number.isSafeInteger(value) && value <= 0x10ffff ? String.fromCodePoint(value) : entity;
177
+ });
178
+ }
179
+
180
+ function mergeMetadata(page: PostMetadata | undefined, watch: PostMetadata | undefined): PostMetadata | undefined {
181
+ if (!page) {
182
+ return watch;
183
+ }
184
+ if (!watch) {
185
+ return page;
186
+ }
187
+ return { ...page, ...watch, author: watch.author ?? page.author };
188
+ }
189
+
89
190
  function source(html: string, key: string): string | null {
90
191
  const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
91
192
  if (!match) {
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,15 @@
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 DownloadBlobOptions,
30
+ type DownloadOptions,
31
+ } from "./download";
24
32
  export { PostfetchError } from "./internal";
25
33
  export type {
26
34
  InstagramExtra,
@@ -37,5 +45,6 @@ export type {
37
45
  SoundcloudExtra,
38
46
  TiktokExtra,
39
47
  TwitterExtra,
48
+ TwitterQuotedTweet,
40
49
  YoutubeExtra,
41
50
  } 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. */
@@ -179,6 +189,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
179
189
  export type ResolveContext = {
180
190
  net: Net;
181
191
  preferredWidth: number;
192
+ /** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
193
+ tryMaxBytes?: number;
182
194
  url: string;
183
195
  };
184
196
 
@@ -0,0 +1,100 @@
1
+ type CommandResult = {
2
+ stderr: string;
3
+ success: boolean;
4
+ };
5
+
6
+ export type Mp4RemuxRuntime = {
7
+ makeTempDir(prefix: string): Promise<string>;
8
+ readFile(path: string): Promise<Uint8Array>;
9
+ removeDir(path: string): Promise<void>;
10
+ run(command: string, args: string[]): Promise<CommandResult>;
11
+ writeFile(path: string, bytes: Uint8Array): Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * Normalize an MP4 with the same stream-copy remux used by UMMR. Encoded media
16
+ * is left untouched; FFmpeg only rebuilds the container and timing tables.
17
+ * Returns `null` when FFmpeg is unavailable or the input cannot be remuxed.
18
+ */
19
+ export async function remuxMp4(
20
+ bytes: Uint8Array,
21
+ ffmpegPath = "ffmpeg",
22
+ runtime?: Mp4RemuxRuntime,
23
+ ): Promise<Uint8Array | null> {
24
+ let directory: string | undefined;
25
+ let io: Mp4RemuxRuntime | undefined;
26
+ try {
27
+ io = runtime ?? (await nodeRuntime());
28
+ directory = await io.makeTempDir("postfetch-remux-");
29
+ const inputPath = `${directory}/input.mp4`;
30
+ const outputPath = `${directory}/output.mp4`;
31
+ await io.writeFile(inputPath, bytes);
32
+ const result = await io.run(ffmpegPath, [
33
+ "-nostdin",
34
+ "-hide_banner",
35
+ "-loglevel",
36
+ "error",
37
+ "-y",
38
+ "-i",
39
+ inputPath,
40
+ "-map",
41
+ "0:v:0",
42
+ "-map",
43
+ "0:a:0?",
44
+ "-c",
45
+ "copy",
46
+ "-movflags",
47
+ "+faststart",
48
+ "-avoid_negative_ts",
49
+ "make_zero",
50
+ "-use_editlist",
51
+ "0",
52
+ outputPath,
53
+ ]);
54
+ if (!result.success) {
55
+ return null;
56
+ }
57
+ const output = await io.readFile(outputPath);
58
+ return output.length > 0 ? output : null;
59
+ } catch {
60
+ return null;
61
+ } finally {
62
+ if (directory && io) {
63
+ await io.removeDir(directory).catch(() => undefined);
64
+ }
65
+ }
66
+ }
67
+
68
+ // Deno, Bun and Node all expose the Node compatibility modules used here. They
69
+ // stay behind dynamic imports so browser/edge consumers that leave remux off do
70
+ // not load or execute any process/filesystem code.
71
+ async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
72
+ const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
73
+ import("node:child_process"),
74
+ import("node:fs/promises"),
75
+ import("node:os"),
76
+ import("node:path"),
77
+ ]);
78
+ return {
79
+ makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
80
+ readFile: async (path) => new Uint8Array(await readFile(path)),
81
+ removeDir: async (path) => {
82
+ await rm(path, { force: true, recursive: true });
83
+ },
84
+ run: (command, args) =>
85
+ new Promise((resolve) => {
86
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
87
+ let stderr = "";
88
+ child.stderr?.on("data", (chunk: Uint8Array) => {
89
+ if (stderr.length < 4096) {
90
+ stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
91
+ }
92
+ });
93
+ child.once("error", (error) => resolve({ stderr: error.message, success: false }));
94
+ child.once("close", (code) => resolve({ stderr, success: code === 0 }));
95
+ }),
96
+ writeFile: async (path, value) => {
97
+ await writeFile(path, value);
98
+ },
99
+ };
100
+ }
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,15 @@ 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. X probes every available MP4 variant and picks the highest-quality
22
+ * complete result under the cap, or its smallest variants when none fit. For
23
+ * other platforms, if size discovery or a smaller rendition is unavailable,
24
+ * the normal result is returned unchanged.
25
+ */
26
+ tryMaxBytes?: number;
18
27
  };
19
28
 
20
29
  /**
@@ -22,8 +31,9 @@ export type PostfetchOptions = {
22
31
  *
23
32
  * Detects the platform from the URL host, then returns the post's media as typed
24
33
  * {@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`.
34
+ * performs no side effects beyond the lookup; use {@link download},
35
+ * {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
36
+ * into bytes, a `Blob` or a `Response`.
27
37
  *
28
38
  * @param url A post URL from a supported platform.
29
39
  * @param options See {@link PostfetchOptions}.
@@ -43,12 +53,23 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
43
53
  if (trimmed.length === 0) {
44
54
  throw new PostfetchError(400, "url is required");
45
55
  }
46
- const context = {
56
+ if (options.tryMaxBytes !== undefined && (!Number.isSafeInteger(options.tryMaxBytes) || options.tryMaxBytes <= 0)) {
57
+ throw new PostfetchError(400, "tryMaxBytes must be a positive integer");
58
+ }
59
+ const context: ResolveContext = {
47
60
  net: createNet(options.fetch ?? globalThis.fetch),
48
61
  preferredWidth: options.preferredWidth ?? 720,
62
+ tryMaxBytes: options.tryMaxBytes,
49
63
  url: trimmed,
50
64
  };
51
- switch (detect(trimmed)) {
65
+ const result = await resolve(context);
66
+ return options.tryMaxBytes === undefined || result.platform === "twitter"
67
+ ? result
68
+ : trySmaller(context, result, options.tryMaxBytes);
69
+ }
70
+
71
+ async function resolve(context: ResolveContext): Promise<PostfetchResult> {
72
+ switch (detect(context.url)) {
52
73
  case "facebook":
53
74
  return resolveFacebook(context);
54
75
  case "instagram":
@@ -70,6 +91,65 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
70
91
  }
71
92
  }
72
93
 
94
+ async function trySmaller(context: ResolveContext, result: PostfetchResult, maxBytes: number): Promise<PostfetchResult> {
95
+ const originalBytes = await resultBytes(context, result);
96
+ if (originalBytes === null || originalBytes <= maxBytes) {
97
+ return result;
98
+ }
99
+ try {
100
+ // Width-aware resolvers interpret 1px as "pick the smallest available".
101
+ // Resolvers without a smaller rendition return the same media unchanged.
102
+ const smaller = await resolve({ ...context, preferredWidth: 1 });
103
+ if (sameMedia(result, smaller)) {
104
+ return result;
105
+ }
106
+ const smallerBytes = await resultBytes(context, smaller);
107
+ return smallerBytes !== null && smallerBytes < originalBytes ? smaller : result;
108
+ } catch {
109
+ return result;
110
+ }
111
+ }
112
+
113
+ async function resultBytes(context: ResolveContext, result: PostfetchResult): Promise<number | null> {
114
+ const sizes = await Promise.all(result.items.map((item) => itemBytes(context, item)));
115
+ if (sizes.some((size) => size === null)) {
116
+ return null;
117
+ }
118
+ return sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
119
+ }
120
+
121
+ async function itemBytes(context: ResolveContext, item: MediaItem): Promise<number | null> {
122
+ if (item.hls) {
123
+ return null;
124
+ }
125
+ const [video, audio] = await Promise.all([
126
+ contentLength(context, item.url, item.headers),
127
+ item.audio ? contentLength(context, item.audio.url, item.audio.headers) : Promise.resolve(0),
128
+ ]);
129
+ return video === null || audio === null ? null : video + audio;
130
+ }
131
+
132
+ async function contentLength(context: ResolveContext, url: string, headers: HeadersInit): Promise<number | null> {
133
+ try {
134
+ const response = await context.net(url, { headers, method: "HEAD" }, 1);
135
+ const raw = response.ok ? response.headers.get("content-length") : null;
136
+ if (!raw || !/^\d+$/.test(raw)) {
137
+ return null;
138
+ }
139
+ const bytes = Number(raw);
140
+ return Number.isSafeInteger(bytes) ? bytes : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function sameMedia(left: PostfetchResult, right: PostfetchResult): boolean {
147
+ return left.items.length === right.items.length && left.items.every((item, index) => {
148
+ const other = right.items[index];
149
+ return item.url === other?.url && item.audio?.url === other.audio?.url;
150
+ });
151
+ }
152
+
73
153
  /**
74
154
  * Detect which platform a URL belongs to, from its host.
75
155
  *