@postfetch/core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/download.d.ts +32 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +381 -39
- package/dist/internal.d.ts +13 -0
- package/dist/mp4-remux.d.ts +18 -0
- package/dist/postfetch.d.ts +12 -2
- package/package.json +1 -1
- package/src/download.ts +68 -0
- package/src/facebook.ts +120 -19
- package/src/index.ts +12 -3
- package/src/internal.ts +12 -0
- package/src/mp4-remux.ts +100 -0
- package/src/postfetch.ts +85 -5
- package/src/twitter.ts +140 -21
package/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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
37
|
-
//
|
|
38
|
-
async function
|
|
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
|
|
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")
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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}
|
|
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 {
|
|
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
|
|
package/src/mp4-remux.ts
ADDED
|
@@ -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},
|
|
26
|
-
* or {@link toResponse} to turn the result
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|