@postfetch/core 0.7.0 → 0.9.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 +10 -4
- package/dist/download.d.ts +34 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +346 -32
- package/dist/internal.d.ts +2 -0
- package/dist/mp4-remux.d.ts +26 -0
- package/dist/postfetch.d.ts +4 -2
- package/package.json +1 -1
- package/src/download.ts +79 -10
- package/src/facebook.ts +94 -7
- package/src/index.ts +2 -0
- package/src/internal.ts +2 -0
- package/src/mp4-remux.ts +197 -0
- package/src/postfetch.ts +8 -3
- package/src/twitter.ts +109 -20
package/README.md
CHANGED
|
@@ -13,12 +13,18 @@ for (const item of result.items) {
|
|
|
13
13
|
|
|
14
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
|
-
`downloadBlob(url,
|
|
16
|
+
`downloadBlob(url, options?)` materializes an already-resolved media URL for
|
|
17
17
|
clients that need an uploadable `Blob`. Pass the resolved item's headers for
|
|
18
|
-
protected CDN URLs: `downloadBlob(item.url, item.headers)`.
|
|
18
|
+
protected CDN URLs: `downloadBlob(item.url, { headers: item.headers })`. Set
|
|
19
|
+
`remux: true` to return `{ blob, thumbnail, width, height, duration }`: a
|
|
20
|
+
normalized MP4, upload thumbnail and calculated presentation metadata. Remuxing
|
|
21
|
+
defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
|
|
22
|
+
set, and throws if the complete result cannot be produced. The legacy
|
|
23
|
+
`(url, headers?, options?)` overload remains supported for non-remux downloads.
|
|
19
24
|
|
|
20
|
-
- `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap
|
|
25
|
+
- `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap. X probes every MP4 variant and selects the highest-quality complete result that fits, falling back to its smallest variants when none do. Other platforms return a smaller available rendition when possible and otherwise keep the normal result. Inject `fetch` to unit-test resolvers offline.
|
|
21
26
|
- Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
|
|
22
|
-
- No runtime dependencies, no `
|
|
27
|
+
- No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
|
|
28
|
+
the opt-in `downloadBlob(..., { remux: true })` path is requested.
|
|
23
29
|
|
|
24
30
|
See the [root README](https://github.com/chelokot/postfetch#readme) for the full API table, supported inputs and the fingerprint design.
|
package/dist/download.d.ts
CHANGED
|
@@ -4,6 +4,25 @@ export type DownloadOptions = {
|
|
|
4
4
|
/** Custom `fetch` implementation. Defaults to the global `fetch`. */
|
|
5
5
|
fetch?: typeof fetch;
|
|
6
6
|
};
|
|
7
|
+
/** Options for {@link downloadBlob}. */
|
|
8
|
+
export type DownloadBlobOptions = DownloadOptions & {
|
|
9
|
+
/** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
|
|
10
|
+
ffmpegPath?: string;
|
|
11
|
+
/** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
|
|
12
|
+
ffprobePath?: string;
|
|
13
|
+
/** Headers required by the resolved media URL. */
|
|
14
|
+
headers?: HeadersInit;
|
|
15
|
+
/** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
|
|
16
|
+
remux?: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** A normalized video and upload metadata produced by `remux: true`. */
|
|
19
|
+
export type RemuxedVideo = {
|
|
20
|
+
blob: Blob;
|
|
21
|
+
duration: number;
|
|
22
|
+
height: number;
|
|
23
|
+
thumbnail: Blob;
|
|
24
|
+
width: number;
|
|
25
|
+
};
|
|
7
26
|
/** A zip archive produced by {@link archive}. */
|
|
8
27
|
export type Archive = {
|
|
9
28
|
/** The zip bytes (store mode, built in-process). */
|
|
@@ -35,20 +54,29 @@ export declare function download(item: MediaItem, options?: DownloadOptions): Pr
|
|
|
35
54
|
*
|
|
36
55
|
* This is useful for consumers that need to upload media themselves instead of
|
|
37
56
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
38
|
-
* required CDN headers, pass them
|
|
57
|
+
* required CDN headers, pass them in the options object. Set `remux: true` to
|
|
58
|
+
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
59
|
+
* remux or metadata extraction fails, the operation throws.
|
|
39
60
|
*
|
|
40
61
|
* @param url A direct media URL returned by a resolver.
|
|
41
|
-
* @param
|
|
42
|
-
* @
|
|
43
|
-
* @returns The downloaded media as a Blob.
|
|
62
|
+
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
63
|
+
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
44
64
|
*
|
|
45
65
|
* @example Upload a video with FormData
|
|
46
66
|
* ```ts
|
|
47
67
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
48
|
-
* const
|
|
49
|
-
* form.append("video", blob, media.filename);
|
|
68
|
+
* const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
69
|
+
* form.append("video", video.blob, media.filename);
|
|
70
|
+
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
50
71
|
* ```
|
|
51
72
|
*/
|
|
73
|
+
export declare function downloadBlob(url: string, options: DownloadBlobOptions & {
|
|
74
|
+
remux: true;
|
|
75
|
+
}): Promise<RemuxedVideo>;
|
|
76
|
+
export declare function downloadBlob(url: string, options?: DownloadBlobOptions & {
|
|
77
|
+
remux?: false;
|
|
78
|
+
}): Promise<Blob>;
|
|
79
|
+
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
52
80
|
export declare function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
53
81
|
/**
|
|
54
82
|
* Download every item of a result and zip them in-process (store mode).
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
|
-
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadOptions, } from "./download";
|
|
23
|
+
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, type RemuxedVideo, } from "./download";
|
|
24
24
|
export { PostfetchError } from "./internal";
|
|
25
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
|
@@ -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,12 +165,17 @@ function instagramAppUserAgent() {
|
|
|
162
165
|
|
|
163
166
|
// src/facebook.ts
|
|
164
167
|
async function resolveFacebook(input) {
|
|
165
|
-
const
|
|
168
|
+
const page = await canonicalPage(input.net, normalizedInputUrl(input.url));
|
|
169
|
+
const canonical = page.url;
|
|
166
170
|
const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
|
|
171
|
+
const pageMetadata = facebookMetadata(page.html);
|
|
167
172
|
const embed = await embedVideo(input.net, canonical, input.preferredWidth);
|
|
168
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) {
|
|
176
|
+
if (pageMetadata?.text) {
|
|
177
|
+
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
178
|
+
}
|
|
171
179
|
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
172
180
|
}
|
|
173
181
|
const item = {
|
|
@@ -179,7 +187,13 @@ async function resolveFacebook(input) {
|
|
|
179
187
|
platform: "facebook",
|
|
180
188
|
url
|
|
181
189
|
};
|
|
182
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
archiveFilename: filename(`facebook_${id}.zip`),
|
|
192
|
+
id,
|
|
193
|
+
items: [item],
|
|
194
|
+
metadata: mergeMetadata(pageMetadata, watch?.metadata),
|
|
195
|
+
platform: "facebook"
|
|
196
|
+
};
|
|
183
197
|
}
|
|
184
198
|
function normalizedInputUrl(input) {
|
|
185
199
|
const url = asUrl(input);
|
|
@@ -193,10 +207,13 @@ function navigationHeaders2() {
|
|
|
193
207
|
"user-agent": browserUserAgent()
|
|
194
208
|
};
|
|
195
209
|
}
|
|
196
|
-
async function
|
|
210
|
+
async function canonicalPage(net, input) {
|
|
197
211
|
const response = await net(input, { headers: navigationHeaders2() });
|
|
198
|
-
const resolved = asUrl(response.url);
|
|
199
|
-
return
|
|
212
|
+
const resolved = asUrl(response.url || input);
|
|
213
|
+
return {
|
|
214
|
+
html: response.ok ? await response.text() : "",
|
|
215
|
+
url: `${resolved.origin}${resolved.pathname}`
|
|
216
|
+
};
|
|
200
217
|
}
|
|
201
218
|
async function embedVideo(net, canonical, preferredWidth) {
|
|
202
219
|
const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
|
|
@@ -231,6 +248,63 @@ function watchMetadata(html) {
|
|
|
231
248
|
viewCount: count(html.match(/"video_view_count":(\d+)/)?.[1])
|
|
232
249
|
};
|
|
233
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
|
+
}
|
|
234
308
|
function source(html, key) {
|
|
235
309
|
const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
|
|
236
310
|
if (!match) {
|
|
@@ -809,7 +883,7 @@ function parseJson(value) {
|
|
|
809
883
|
return JSON.parse(value);
|
|
810
884
|
} catch {
|
|
811
885
|
try {
|
|
812
|
-
return JSON.parse(
|
|
886
|
+
return JSON.parse(decodeHtml2(value));
|
|
813
887
|
} catch {
|
|
814
888
|
return null;
|
|
815
889
|
}
|
|
@@ -844,7 +918,7 @@ function bestVideoUrl(html) {
|
|
|
844
918
|
}
|
|
845
919
|
let parsed;
|
|
846
920
|
try {
|
|
847
|
-
parsed = JSON.parse(
|
|
921
|
+
parsed = JSON.parse(decodeHtml2(encoded));
|
|
848
922
|
} catch {
|
|
849
923
|
return null;
|
|
850
924
|
}
|
|
@@ -881,7 +955,7 @@ function linkedinHandle(value) {
|
|
|
881
955
|
return null;
|
|
882
956
|
}
|
|
883
957
|
}
|
|
884
|
-
function
|
|
958
|
+
function decodeHtml2(value) {
|
|
885
959
|
return value.replace(/&(quot|amp|lt|gt|#39|#x27|#(\d+)|#x([0-9a-f]+));/gi, (_entity, name, decimal, hex) => {
|
|
886
960
|
switch (name.toLowerCase()) {
|
|
887
961
|
case "quot":
|
|
@@ -1541,7 +1615,7 @@ function extensionOf(mime) {
|
|
|
1541
1615
|
|
|
1542
1616
|
// src/soundcloud.ts
|
|
1543
1617
|
async function resolveSoundcloud(input) {
|
|
1544
|
-
const url = await
|
|
1618
|
+
const url = await canonicalUrl(input.net, input.url);
|
|
1545
1619
|
const clientId = await appClientId(input.net);
|
|
1546
1620
|
const track = await resolveTrack(input.net, url, clientId);
|
|
1547
1621
|
const id = trackId(track);
|
|
@@ -1567,7 +1641,7 @@ function soundcloudMetadata(track) {
|
|
|
1567
1641
|
}
|
|
1568
1642
|
};
|
|
1569
1643
|
}
|
|
1570
|
-
async function
|
|
1644
|
+
async function canonicalUrl(net, input) {
|
|
1571
1645
|
const host = asUrl(input).hostname;
|
|
1572
1646
|
if (host !== "on.soundcloud.com" && host !== "snd.sc") {
|
|
1573
1647
|
return input;
|
|
@@ -1935,13 +2009,14 @@ async function resolveTwitter(input) {
|
|
|
1935
2009
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
1936
2010
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
1937
2011
|
}
|
|
1938
|
-
const
|
|
2012
|
+
const groups = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
|
|
1939
2013
|
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
1940
|
-
return media.flatMap((entry, index) =>
|
|
2014
|
+
return media.flatMap((entry, index) => {
|
|
2015
|
+
const candidates = twitterCandidates(entry, postId2, index + 1);
|
|
2016
|
+
return candidates.length > 0 ? [candidates] : [];
|
|
2017
|
+
});
|
|
1941
2018
|
});
|
|
1942
|
-
|
|
1943
|
-
throw new Error("Twitter media not found");
|
|
1944
|
-
}
|
|
2019
|
+
const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
|
|
1945
2020
|
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
1946
2021
|
}
|
|
1947
2022
|
function twitterMetadata(tweet) {
|
|
@@ -2004,25 +2079,92 @@ async function syndication(net, id) {
|
|
|
2004
2079
|
function syndicationToken(id) {
|
|
2005
2080
|
return (Number(id) / 1000000000000000 * Math.PI).toString(36).replace(/(0+|\.)/g, "");
|
|
2006
2081
|
}
|
|
2007
|
-
function
|
|
2082
|
+
function twitterCandidates(entry, id, index) {
|
|
2008
2083
|
const headers = { "user-agent": browserUserAgent() };
|
|
2009
2084
|
const type = string(entry.type);
|
|
2010
2085
|
if (type === "video" || type === "animated_gif") {
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
+
}));
|
|
2013
2090
|
}
|
|
2014
2091
|
const photo = string(entry.media_url_https);
|
|
2015
|
-
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` } }] : [];
|
|
2016
2093
|
}
|
|
2017
|
-
function
|
|
2094
|
+
function videoVariants(entry) {
|
|
2018
2095
|
const info = object(entry.video_info) ? entry.video_info : null;
|
|
2019
2096
|
const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
|
|
2020
|
-
const
|
|
2021
|
-
|
|
2022
|
-
const
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
+
}
|
|
2026
2168
|
}
|
|
2027
2169
|
function tweetId(input) {
|
|
2028
2170
|
return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
|
|
@@ -2260,10 +2402,11 @@ async function postfetch(url, options = {}) {
|
|
|
2260
2402
|
const context = {
|
|
2261
2403
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
2262
2404
|
preferredWidth: options.preferredWidth ?? 720,
|
|
2405
|
+
tryMaxBytes: options.tryMaxBytes,
|
|
2263
2406
|
url: trimmed
|
|
2264
2407
|
};
|
|
2265
2408
|
const result = await resolve(context);
|
|
2266
|
-
return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
|
|
2409
|
+
return options.tryMaxBytes === undefined || result.platform === "twitter" ? result : trySmaller(context, result, options.tryMaxBytes);
|
|
2267
2410
|
}
|
|
2268
2411
|
async function resolve(context) {
|
|
2269
2412
|
switch (detect(context.url)) {
|
|
@@ -2315,12 +2458,12 @@ async function itemBytes(context, item) {
|
|
|
2315
2458
|
return null;
|
|
2316
2459
|
}
|
|
2317
2460
|
const [video, audio] = await Promise.all([
|
|
2318
|
-
|
|
2319
|
-
item.audio ?
|
|
2461
|
+
contentLength2(context, item.url, item.headers),
|
|
2462
|
+
item.audio ? contentLength2(context, item.audio.url, item.audio.headers) : Promise.resolve(0)
|
|
2320
2463
|
]);
|
|
2321
2464
|
return video === null || audio === null ? null : video + audio;
|
|
2322
2465
|
}
|
|
2323
|
-
async function
|
|
2466
|
+
async function contentLength2(context, url, headers) {
|
|
2324
2467
|
try {
|
|
2325
2468
|
const response = await context.net(url, { headers, method: "HEAD" }, 1);
|
|
2326
2469
|
const raw = response.ok ? response.headers.get("content-length") : null;
|
|
@@ -2370,6 +2513,153 @@ function detect(url) {
|
|
|
2370
2513
|
}
|
|
2371
2514
|
throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
2372
2515
|
}
|
|
2516
|
+
// src/mp4-remux.ts
|
|
2517
|
+
async function prepareMp4(bytes, ffmpegPath = "ffmpeg", ffprobePath = "ffprobe", 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
|
+
if (output.length === 0) {
|
|
2553
|
+
return null;
|
|
2554
|
+
}
|
|
2555
|
+
const thumbnailPath = `${directory}/thumbnail.jpg`;
|
|
2556
|
+
const thumbnailResult = await io.run(ffmpegPath, [
|
|
2557
|
+
"-nostdin",
|
|
2558
|
+
"-hide_banner",
|
|
2559
|
+
"-loglevel",
|
|
2560
|
+
"error",
|
|
2561
|
+
"-y",
|
|
2562
|
+
"-i",
|
|
2563
|
+
outputPath,
|
|
2564
|
+
"-map",
|
|
2565
|
+
"0:v:0",
|
|
2566
|
+
"-frames:v",
|
|
2567
|
+
"1",
|
|
2568
|
+
"-vf",
|
|
2569
|
+
"scale=320:320:force_original_aspect_ratio=decrease",
|
|
2570
|
+
"-c:v",
|
|
2571
|
+
"mjpeg",
|
|
2572
|
+
"-q:v",
|
|
2573
|
+
"5",
|
|
2574
|
+
thumbnailPath
|
|
2575
|
+
]);
|
|
2576
|
+
if (!thumbnailResult.success) {
|
|
2577
|
+
return null;
|
|
2578
|
+
}
|
|
2579
|
+
const thumbnail = await io.readFile(thumbnailPath);
|
|
2580
|
+
if (!validThumbnail(thumbnail)) {
|
|
2581
|
+
return null;
|
|
2582
|
+
}
|
|
2583
|
+
const probe = await io.run(ffprobePath, [
|
|
2584
|
+
"-v",
|
|
2585
|
+
"error",
|
|
2586
|
+
"-select_streams",
|
|
2587
|
+
"v:0",
|
|
2588
|
+
"-show_entries",
|
|
2589
|
+
"stream=width,height,duration:stream_side_data=rotation:format=duration",
|
|
2590
|
+
"-of",
|
|
2591
|
+
"json",
|
|
2592
|
+
outputPath
|
|
2593
|
+
]);
|
|
2594
|
+
if (!probe.success) {
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
const metadata = parseVideoMetadata(probe.stdout);
|
|
2598
|
+
return metadata ? { bytes: output, thumbnail, ...metadata } : null;
|
|
2599
|
+
} catch {
|
|
2600
|
+
return null;
|
|
2601
|
+
} finally {
|
|
2602
|
+
if (directory && io) {
|
|
2603
|
+
await io.removeDir(directory).catch(() => {
|
|
2604
|
+
return;
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
function validThumbnail(bytes) {
|
|
2610
|
+
return bytes.length >= 4 && bytes.length < 200000 && bytes[0] === 255 && bytes[1] === 216 && bytes[bytes.length - 2] === 255 && bytes[bytes.length - 1] === 217;
|
|
2611
|
+
}
|
|
2612
|
+
function parseVideoMetadata(value) {
|
|
2613
|
+
const parsed = JSON.parse(value);
|
|
2614
|
+
const stream = parsed.streams?.[0];
|
|
2615
|
+
const duration = Number(parsed.format?.duration ?? stream?.duration);
|
|
2616
|
+
const width = stream?.width ?? 0;
|
|
2617
|
+
const height2 = stream?.height ?? 0;
|
|
2618
|
+
if (!stream || !(duration > 0 && Number.isFinite(duration) && width > 0 && height2 > 0)) {
|
|
2619
|
+
return null;
|
|
2620
|
+
}
|
|
2621
|
+
const rotation = stream.side_data_list?.find((item) => item.rotation !== undefined)?.rotation ?? 0;
|
|
2622
|
+
const sideways = Math.abs(rotation) % 180 === 90;
|
|
2623
|
+
return {
|
|
2624
|
+
duration: Math.ceil(duration),
|
|
2625
|
+
height: sideways ? width : height2,
|
|
2626
|
+
width: sideways ? height2 : width
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
async function nodeRuntime() {
|
|
2630
|
+
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
2631
|
+
import("node:child_process"),
|
|
2632
|
+
import("node:fs/promises"),
|
|
2633
|
+
import("node:os"),
|
|
2634
|
+
import("node:path")
|
|
2635
|
+
]);
|
|
2636
|
+
return {
|
|
2637
|
+
makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
|
|
2638
|
+
readFile: async (path) => new Uint8Array(await readFile(path)),
|
|
2639
|
+
removeDir: async (path) => {
|
|
2640
|
+
await rm(path, { force: true, recursive: true });
|
|
2641
|
+
},
|
|
2642
|
+
run: (command, args) => new Promise((resolve2) => {
|
|
2643
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2644
|
+
let stderr = "";
|
|
2645
|
+
let stdout = "";
|
|
2646
|
+
child.stdout?.on("data", (chunk) => {
|
|
2647
|
+
stdout += new TextDecoder().decode(chunk);
|
|
2648
|
+
});
|
|
2649
|
+
child.stderr?.on("data", (chunk) => {
|
|
2650
|
+
if (stderr.length < 4096) {
|
|
2651
|
+
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
child.once("error", (error) => resolve2({ stderr: error.message, stdout, success: false }));
|
|
2655
|
+
child.once("close", (code) => resolve2({ stderr, stdout, success: code === 0 }));
|
|
2656
|
+
}),
|
|
2657
|
+
writeFile: async (path, value) => {
|
|
2658
|
+
await writeFile(path, value);
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2373
2663
|
// src/remux.ts
|
|
2374
2664
|
function mergeAudioVideo(video, audio) {
|
|
2375
2665
|
const videoSource = analyze(video);
|
|
@@ -2605,13 +2895,37 @@ async function download(item, options = {}) {
|
|
|
2605
2895
|
}
|
|
2606
2896
|
return response;
|
|
2607
2897
|
}
|
|
2608
|
-
async function downloadBlob(url,
|
|
2898
|
+
async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
|
|
2899
|
+
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
2900
|
+
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
2901
|
+
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
2609
2902
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2610
2903
|
const response = await net(url, { headers });
|
|
2611
2904
|
if (!response.ok || !response.body) {
|
|
2612
2905
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
2613
2906
|
}
|
|
2614
|
-
|
|
2907
|
+
const blob = await response.blob();
|
|
2908
|
+
if (!modern || optionsOrHeaders.remux !== true) {
|
|
2909
|
+
return blob;
|
|
2910
|
+
}
|
|
2911
|
+
const prepared = await prepareMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath, optionsOrHeaders.ffprobePath);
|
|
2912
|
+
if (!prepared) {
|
|
2913
|
+
throw new PostfetchError(500, "MP4 remux or metadata extraction failed");
|
|
2914
|
+
}
|
|
2915
|
+
return {
|
|
2916
|
+
blob: new Blob([toArrayBuffer(prepared.bytes)], { type: blob.type || "video/mp4" }),
|
|
2917
|
+
duration: prepared.duration,
|
|
2918
|
+
height: prepared.height,
|
|
2919
|
+
thumbnail: new Blob([toArrayBuffer(prepared.thumbnail)], { type: "image/jpeg" }),
|
|
2920
|
+
width: prepared.width
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
function isDownloadBlobOptions(value) {
|
|
2924
|
+
if (value instanceof Headers || Array.isArray(value)) {
|
|
2925
|
+
return false;
|
|
2926
|
+
}
|
|
2927
|
+
const candidate = value;
|
|
2928
|
+
return "headers" in candidate && typeof candidate.headers !== "string" || typeof candidate.remux === "boolean" || typeof candidate.ffmpegPath === "string" || typeof candidate.ffprobePath === "string" || typeof candidate.fetch === "function";
|
|
2615
2929
|
}
|
|
2616
2930
|
async function archive(result, options = {}) {
|
|
2617
2931
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
package/dist/internal.d.ts
CHANGED
|
@@ -169,6 +169,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
|
|
|
169
169
|
export type ResolveContext = {
|
|
170
170
|
net: Net;
|
|
171
171
|
preferredWidth: number;
|
|
172
|
+
/** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
|
|
173
|
+
tryMaxBytes?: number;
|
|
172
174
|
url: string;
|
|
173
175
|
};
|
|
174
176
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type CommandResult = {
|
|
2
|
+
stderr: string;
|
|
3
|
+
stdout: string;
|
|
4
|
+
success: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type PreparedMp4 = {
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
duration: number;
|
|
9
|
+
height: number;
|
|
10
|
+
thumbnail: Uint8Array;
|
|
11
|
+
width: number;
|
|
12
|
+
};
|
|
13
|
+
export type Mp4RemuxRuntime = {
|
|
14
|
+
makeTempDir(prefix: string): Promise<string>;
|
|
15
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
16
|
+
removeDir(path: string): Promise<void>;
|
|
17
|
+
run(command: string, args: string[]): Promise<CommandResult>;
|
|
18
|
+
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Normalize an MP4, extract a Telegram-compatible JPEG thumbnail, and calculate
|
|
22
|
+
* the presentation dimensions and duration. Encoded media is left untouched;
|
|
23
|
+
* FFmpeg only rebuilds the container and timing tables.
|
|
24
|
+
*/
|
|
25
|
+
export declare function prepareMp4(bytes: Uint8Array, ffmpegPath?: string, ffprobePath?: string, runtime?: Mp4RemuxRuntime): Promise<PreparedMp4 | null>;
|
|
26
|
+
export {};
|
package/dist/postfetch.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ export type PostfetchOptions = {
|
|
|
8
8
|
/**
|
|
9
9
|
* Soft file-size cap in bytes. The normal rendition is resolved and probed
|
|
10
10
|
* with HEAD; when it exceeds the cap, a smaller available rendition is
|
|
11
|
-
* returned.
|
|
12
|
-
*
|
|
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.
|
|
13
15
|
*/
|
|
14
16
|
tryMaxBytes?: number;
|
|
15
17
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
@@ -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 { prepareMp4 } from "./mp4-remux";
|
|
3
4
|
import { mergeAudioVideo } from "./remux";
|
|
4
5
|
import { zip } from "./zip";
|
|
5
6
|
|
|
@@ -15,6 +16,27 @@ 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
|
+
/** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
|
|
24
|
+
ffprobePath?: string;
|
|
25
|
+
/** Headers required by the resolved media URL. */
|
|
26
|
+
headers?: HeadersInit;
|
|
27
|
+
/** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
|
|
28
|
+
remux?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A normalized video and upload metadata produced by `remux: true`. */
|
|
32
|
+
export type RemuxedVideo = {
|
|
33
|
+
blob: Blob;
|
|
34
|
+
duration: number;
|
|
35
|
+
height: number;
|
|
36
|
+
thumbnail: Blob;
|
|
37
|
+
width: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
18
40
|
/** A zip archive produced by {@link archive}. */
|
|
19
41
|
export type Archive = {
|
|
20
42
|
/** The zip bytes (store mode, built in-process). */
|
|
@@ -59,31 +81,78 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
|
|
|
59
81
|
*
|
|
60
82
|
* This is useful for consumers that need to upload media themselves instead of
|
|
61
83
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
62
|
-
* required CDN headers, pass them
|
|
84
|
+
* required CDN headers, pass them in the options object. Set `remux: true` to
|
|
85
|
+
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
86
|
+
* remux or metadata extraction fails, the operation throws.
|
|
63
87
|
*
|
|
64
88
|
* @param url A direct media URL returned by a resolver.
|
|
65
|
-
* @param
|
|
66
|
-
* @
|
|
67
|
-
* @returns The downloaded media as a Blob.
|
|
89
|
+
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
90
|
+
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
68
91
|
*
|
|
69
92
|
* @example Upload a video with FormData
|
|
70
93
|
* ```ts
|
|
71
94
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
72
|
-
* const
|
|
73
|
-
* form.append("video", blob, media.filename);
|
|
95
|
+
* const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
96
|
+
* form.append("video", video.blob, media.filename);
|
|
97
|
+
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
74
98
|
* ```
|
|
75
99
|
*/
|
|
76
100
|
export async function downloadBlob(
|
|
77
101
|
url: string,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
options: DownloadBlobOptions & { remux: true },
|
|
103
|
+
): Promise<RemuxedVideo>;
|
|
104
|
+
export async function downloadBlob(
|
|
105
|
+
url: string,
|
|
106
|
+
options?: DownloadBlobOptions & { remux?: false },
|
|
107
|
+
): Promise<Blob>;
|
|
108
|
+
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
109
|
+
export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
110
|
+
export async function downloadBlob(
|
|
111
|
+
url: string,
|
|
112
|
+
optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
|
|
113
|
+
legacyOptions: DownloadOptions = {},
|
|
114
|
+
): Promise<Blob | RemuxedVideo> {
|
|
115
|
+
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
116
|
+
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
117
|
+
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
81
118
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
82
119
|
const response = await net(url, { headers });
|
|
83
120
|
if (!response.ok || !response.body) {
|
|
84
121
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
85
122
|
}
|
|
86
|
-
|
|
123
|
+
const blob = await response.blob();
|
|
124
|
+
if (!modern || optionsOrHeaders.remux !== true) {
|
|
125
|
+
return blob;
|
|
126
|
+
}
|
|
127
|
+
const prepared = await prepareMp4(
|
|
128
|
+
new Uint8Array(await blob.arrayBuffer()),
|
|
129
|
+
optionsOrHeaders.ffmpegPath,
|
|
130
|
+
optionsOrHeaders.ffprobePath,
|
|
131
|
+
);
|
|
132
|
+
if (!prepared) {
|
|
133
|
+
throw new PostfetchError(500, "MP4 remux or metadata extraction failed");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
blob: new Blob([toArrayBuffer(prepared.bytes)], { type: blob.type || "video/mp4" }),
|
|
137
|
+
duration: prepared.duration,
|
|
138
|
+
height: prepared.height,
|
|
139
|
+
thumbnail: new Blob([toArrayBuffer(prepared.thumbnail)], { type: "image/jpeg" }),
|
|
140
|
+
width: prepared.width,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDownloadBlobOptions(value: DownloadBlobOptions | HeadersInit): value is DownloadBlobOptions {
|
|
145
|
+
if (value instanceof Headers || Array.isArray(value)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const candidate = value as Record<string, unknown>;
|
|
149
|
+
return (
|
|
150
|
+
("headers" in candidate && typeof candidate.headers !== "string") ||
|
|
151
|
+
typeof candidate.remux === "boolean" ||
|
|
152
|
+
typeof candidate.ffmpegPath === "string" ||
|
|
153
|
+
typeof candidate.ffprobePath === "string" ||
|
|
154
|
+
typeof candidate.fetch === "function"
|
|
155
|
+
);
|
|
87
156
|
}
|
|
88
157
|
|
|
89
158
|
/**
|
package/src/facebook.ts
CHANGED
|
@@ -2,14 +2,22 @@ import { asUrl, count, filename, isoFromEpochSeconds, PostfetchError, type PostM
|
|
|
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
11
|
const embed = await embedVideo(input.net, canonical, input.preferredWidth);
|
|
10
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) {
|
|
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
|
+
}
|
|
13
21
|
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
14
22
|
}
|
|
15
23
|
const item: MediaItem = {
|
|
@@ -21,7 +29,13 @@ 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
|
+
};
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
// Facebook share links are opaque shortlinks. Messaging apps sometimes append
|
|
@@ -43,12 +57,15 @@ function navigationHeaders(): Record<string, string> {
|
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
// Share and fb.watch links redirect to the canonical /reel/<id> or /<page>/videos/<id>
|
|
46
|
-
// URL, which the public embed player needs.
|
|
47
|
-
//
|
|
48
|
-
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 }> {
|
|
49
63
|
const response = await net(input, { headers: navigationHeaders() });
|
|
50
|
-
const resolved = asUrl(response.url);
|
|
51
|
-
return
|
|
64
|
+
const resolved = asUrl(response.url || input);
|
|
65
|
+
return {
|
|
66
|
+
html: response.ok ? await response.text() : "",
|
|
67
|
+
url: `${resolved.origin}${resolved.pathname}`,
|
|
68
|
+
};
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
// The logged-out watch page is fingerprint-walled, but the public embed player
|
|
@@ -100,6 +117,76 @@ function watchMetadata(html: string): PostMetadata {
|
|
|
100
117
|
};
|
|
101
118
|
}
|
|
102
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
|
+
|
|
103
190
|
function source(html: string, key: string): string | null {
|
|
104
191
|
const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
|
|
105
192
|
if (!match) {
|
package/src/index.ts
CHANGED
package/src/internal.ts
CHANGED
|
@@ -189,6 +189,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
|
|
|
189
189
|
export type ResolveContext = {
|
|
190
190
|
net: Net;
|
|
191
191
|
preferredWidth: number;
|
|
192
|
+
/** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
|
|
193
|
+
tryMaxBytes?: number;
|
|
192
194
|
url: string;
|
|
193
195
|
};
|
|
194
196
|
|
package/src/mp4-remux.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
type CommandResult = {
|
|
2
|
+
stderr: string;
|
|
3
|
+
stdout: string;
|
|
4
|
+
success: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type PreparedMp4 = {
|
|
8
|
+
bytes: Uint8Array;
|
|
9
|
+
duration: number;
|
|
10
|
+
height: number;
|
|
11
|
+
thumbnail: Uint8Array;
|
|
12
|
+
width: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Mp4RemuxRuntime = {
|
|
16
|
+
makeTempDir(prefix: string): Promise<string>;
|
|
17
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
18
|
+
removeDir(path: string): Promise<void>;
|
|
19
|
+
run(command: string, args: string[]): Promise<CommandResult>;
|
|
20
|
+
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize an MP4, extract a Telegram-compatible JPEG thumbnail, and calculate
|
|
25
|
+
* the presentation dimensions and duration. Encoded media is left untouched;
|
|
26
|
+
* FFmpeg only rebuilds the container and timing tables.
|
|
27
|
+
*/
|
|
28
|
+
export async function prepareMp4(
|
|
29
|
+
bytes: Uint8Array,
|
|
30
|
+
ffmpegPath = "ffmpeg",
|
|
31
|
+
ffprobePath = "ffprobe",
|
|
32
|
+
runtime?: Mp4RemuxRuntime,
|
|
33
|
+
): Promise<PreparedMp4 | null> {
|
|
34
|
+
let directory: string | undefined;
|
|
35
|
+
let io: Mp4RemuxRuntime | undefined;
|
|
36
|
+
try {
|
|
37
|
+
io = runtime ?? (await nodeRuntime());
|
|
38
|
+
directory = await io.makeTempDir("postfetch-remux-");
|
|
39
|
+
const inputPath = `${directory}/input.mp4`;
|
|
40
|
+
const outputPath = `${directory}/output.mp4`;
|
|
41
|
+
await io.writeFile(inputPath, bytes);
|
|
42
|
+
const result = await io.run(ffmpegPath, [
|
|
43
|
+
"-nostdin",
|
|
44
|
+
"-hide_banner",
|
|
45
|
+
"-loglevel",
|
|
46
|
+
"error",
|
|
47
|
+
"-y",
|
|
48
|
+
"-i",
|
|
49
|
+
inputPath,
|
|
50
|
+
"-map",
|
|
51
|
+
"0:v:0",
|
|
52
|
+
"-map",
|
|
53
|
+
"0:a:0?",
|
|
54
|
+
"-c",
|
|
55
|
+
"copy",
|
|
56
|
+
"-movflags",
|
|
57
|
+
"+faststart",
|
|
58
|
+
"-avoid_negative_ts",
|
|
59
|
+
"make_zero",
|
|
60
|
+
"-use_editlist",
|
|
61
|
+
"0",
|
|
62
|
+
outputPath,
|
|
63
|
+
]);
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const output = await io.readFile(outputPath);
|
|
68
|
+
if (output.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const thumbnailPath = `${directory}/thumbnail.jpg`;
|
|
72
|
+
const thumbnailResult = await io.run(ffmpegPath, [
|
|
73
|
+
"-nostdin",
|
|
74
|
+
"-hide_banner",
|
|
75
|
+
"-loglevel",
|
|
76
|
+
"error",
|
|
77
|
+
"-y",
|
|
78
|
+
"-i",
|
|
79
|
+
outputPath,
|
|
80
|
+
"-map",
|
|
81
|
+
"0:v:0",
|
|
82
|
+
"-frames:v",
|
|
83
|
+
"1",
|
|
84
|
+
"-vf",
|
|
85
|
+
"scale=320:320:force_original_aspect_ratio=decrease",
|
|
86
|
+
"-c:v",
|
|
87
|
+
"mjpeg",
|
|
88
|
+
"-q:v",
|
|
89
|
+
"5",
|
|
90
|
+
thumbnailPath,
|
|
91
|
+
]);
|
|
92
|
+
if (!thumbnailResult.success) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const thumbnail = await io.readFile(thumbnailPath);
|
|
96
|
+
if (!validThumbnail(thumbnail)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const probe = await io.run(ffprobePath, [
|
|
100
|
+
"-v",
|
|
101
|
+
"error",
|
|
102
|
+
"-select_streams",
|
|
103
|
+
"v:0",
|
|
104
|
+
"-show_entries",
|
|
105
|
+
"stream=width,height,duration:stream_side_data=rotation:format=duration",
|
|
106
|
+
"-of",
|
|
107
|
+
"json",
|
|
108
|
+
outputPath,
|
|
109
|
+
]);
|
|
110
|
+
if (!probe.success) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const metadata = parseVideoMetadata(probe.stdout);
|
|
114
|
+
return metadata ? { bytes: output, thumbnail, ...metadata } : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
} finally {
|
|
118
|
+
if (directory && io) {
|
|
119
|
+
await io.removeDir(directory).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validThumbnail(bytes: Uint8Array): boolean {
|
|
125
|
+
return (
|
|
126
|
+
bytes.length >= 4 &&
|
|
127
|
+
bytes.length < 200_000 &&
|
|
128
|
+
bytes[0] === 0xff &&
|
|
129
|
+
bytes[1] === 0xd8 &&
|
|
130
|
+
bytes[bytes.length - 2] === 0xff &&
|
|
131
|
+
bytes[bytes.length - 1] === 0xd9
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseVideoMetadata(value: string): Pick<PreparedMp4, "duration" | "height" | "width"> | null {
|
|
136
|
+
const parsed = JSON.parse(value) as {
|
|
137
|
+
format?: { duration?: string };
|
|
138
|
+
streams?: Array<{
|
|
139
|
+
duration?: string;
|
|
140
|
+
height?: number;
|
|
141
|
+
side_data_list?: Array<{ rotation?: number }>;
|
|
142
|
+
width?: number;
|
|
143
|
+
}>;
|
|
144
|
+
};
|
|
145
|
+
const stream = parsed.streams?.[0];
|
|
146
|
+
const duration = Number(parsed.format?.duration ?? stream?.duration);
|
|
147
|
+
const width = stream?.width ?? 0;
|
|
148
|
+
const height = stream?.height ?? 0;
|
|
149
|
+
if (!stream || !(duration > 0 && Number.isFinite(duration) && width > 0 && height > 0)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const rotation = stream.side_data_list?.find((item) => item.rotation !== undefined)?.rotation ?? 0;
|
|
153
|
+
const sideways = Math.abs(rotation) % 180 === 90;
|
|
154
|
+
return {
|
|
155
|
+
duration: Math.ceil(duration),
|
|
156
|
+
height: sideways ? width : height,
|
|
157
|
+
width: sideways ? height : width,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Deno, Bun and Node all expose the Node compatibility modules used here. They
|
|
162
|
+
// stay behind dynamic imports so browser/edge consumers that leave remux off do
|
|
163
|
+
// not load or execute any process/filesystem code.
|
|
164
|
+
async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
|
|
165
|
+
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
166
|
+
import("node:child_process"),
|
|
167
|
+
import("node:fs/promises"),
|
|
168
|
+
import("node:os"),
|
|
169
|
+
import("node:path"),
|
|
170
|
+
]);
|
|
171
|
+
return {
|
|
172
|
+
makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
|
|
173
|
+
readFile: async (path) => new Uint8Array(await readFile(path)),
|
|
174
|
+
removeDir: async (path) => {
|
|
175
|
+
await rm(path, { force: true, recursive: true });
|
|
176
|
+
},
|
|
177
|
+
run: (command, args) =>
|
|
178
|
+
new Promise((resolve) => {
|
|
179
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
180
|
+
let stderr = "";
|
|
181
|
+
let stdout = "";
|
|
182
|
+
child.stdout?.on("data", (chunk: Uint8Array) => {
|
|
183
|
+
stdout += new TextDecoder().decode(chunk);
|
|
184
|
+
});
|
|
185
|
+
child.stderr?.on("data", (chunk: Uint8Array) => {
|
|
186
|
+
if (stderr.length < 4096) {
|
|
187
|
+
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
child.once("error", (error) => resolve({ stderr: error.message, stdout, success: false }));
|
|
191
|
+
child.once("close", (code) => resolve({ stderr, stdout, success: code === 0 }));
|
|
192
|
+
}),
|
|
193
|
+
writeFile: async (path, value) => {
|
|
194
|
+
await writeFile(path, value);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/postfetch.ts
CHANGED
|
@@ -18,8 +18,10 @@ export type PostfetchOptions = {
|
|
|
18
18
|
/**
|
|
19
19
|
* Soft file-size cap in bytes. The normal rendition is resolved and probed
|
|
20
20
|
* with HEAD; when it exceeds the cap, a smaller available rendition is
|
|
21
|
-
* returned.
|
|
22
|
-
*
|
|
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.
|
|
23
25
|
*/
|
|
24
26
|
tryMaxBytes?: number;
|
|
25
27
|
};
|
|
@@ -57,10 +59,13 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
|
|
|
57
59
|
const context: ResolveContext = {
|
|
58
60
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
59
61
|
preferredWidth: options.preferredWidth ?? 720,
|
|
62
|
+
tryMaxBytes: options.tryMaxBytes,
|
|
60
63
|
url: trimmed,
|
|
61
64
|
};
|
|
62
65
|
const result = await resolve(context);
|
|
63
|
-
return options.tryMaxBytes === undefined
|
|
66
|
+
return options.tryMaxBytes === undefined || result.platform === "twitter"
|
|
67
|
+
? result
|
|
68
|
+
: trySmaller(context, result, options.tryMaxBytes);
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
async function resolve(context: ResolveContext): Promise<PostfetchResult> {
|
package/src/twitter.ts
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
} from "./internal";
|
|
18
18
|
import { browserUserAgent } from "./fingerprint";
|
|
19
19
|
|
|
20
|
+
type TwitterCandidate = {
|
|
21
|
+
bitrate: number;
|
|
22
|
+
item: MediaItem;
|
|
23
|
+
};
|
|
24
|
+
|
|
20
25
|
export async function resolveTwitter(input: ResolveContext): Promise<PostfetchResult> {
|
|
21
26
|
const id = tweetId(input.url);
|
|
22
27
|
if (!id) {
|
|
@@ -26,13 +31,17 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
26
31
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
27
32
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
28
33
|
}
|
|
29
|
-
const
|
|
34
|
+
const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
|
|
30
35
|
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
31
|
-
return media.flatMap((entry, index) =>
|
|
36
|
+
return media.flatMap((entry, index) => {
|
|
37
|
+
const candidates = twitterCandidates(entry, postId, index + 1);
|
|
38
|
+
return candidates.length > 0 ? [candidates] : [];
|
|
39
|
+
});
|
|
32
40
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
|
|
42
|
+
// A text-only tweet is a valid result with metadata and no media. Syndication
|
|
43
|
+
// has already failed or returned a tombstone when the tweet is unavailable,
|
|
44
|
+
// so an empty item list here does not mean the lookup failed.
|
|
36
45
|
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -104,32 +113,112 @@ function syndicationToken(id: string): string {
|
|
|
104
113
|
return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
function
|
|
116
|
+
function twitterCandidates(entry: Json, id: string, index: number): TwitterCandidate[] {
|
|
108
117
|
const headers = { "user-agent": browserUserAgent() };
|
|
109
118
|
const type = string(entry.type);
|
|
110
119
|
if (type === "video" || type === "animated_gif") {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
return videoVariants(entry).map(({ bitrate, url }) => ({
|
|
121
|
+
bitrate,
|
|
122
|
+
item: { filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url },
|
|
123
|
+
}));
|
|
115
124
|
}
|
|
116
125
|
const photo = string(entry.media_url_https);
|
|
117
126
|
return photo
|
|
118
|
-
? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }]
|
|
127
|
+
? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }]
|
|
119
128
|
: [];
|
|
120
129
|
}
|
|
121
130
|
|
|
122
|
-
function
|
|
131
|
+
function videoVariants(entry: Json): Array<{ bitrate: number; url: string }> {
|
|
123
132
|
const info = object(entry.video_info) ? entry.video_info : null;
|
|
124
133
|
const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
return variants
|
|
136
|
+
.flatMap((variant) => {
|
|
137
|
+
const url = string(variant.url);
|
|
138
|
+
if (string(variant.content_type) !== "video/mp4" || !url || seen.has(url)) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
seen.add(url);
|
|
142
|
+
return [{ bitrate: number(variant.bitrate) ?? 0, url }];
|
|
143
|
+
})
|
|
144
|
+
.sort((left, right) => right.bitrate - left.bitrate);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function sizeLimitedItems(input: ResolveContext, groups: TwitterCandidate[][]): Promise<MediaItem[]> {
|
|
148
|
+
if (groups.length === 0 || input.tryMaxBytes === undefined) {
|
|
149
|
+
return groups.map((group) => group[0].item);
|
|
150
|
+
}
|
|
151
|
+
const sizes = new Map<string, Promise<number | null>>();
|
|
152
|
+
const size = (candidate: TwitterCandidate): Promise<number | null> => {
|
|
153
|
+
const existing = sizes.get(candidate.item.url);
|
|
154
|
+
if (existing) {
|
|
155
|
+
return existing;
|
|
156
|
+
}
|
|
157
|
+
const pending = contentLength(input.net, candidate.item);
|
|
158
|
+
sizes.set(candidate.item.url, pending);
|
|
159
|
+
return pending;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const highest = groups.map((group) => group[0]);
|
|
163
|
+
const highestSizes = await Promise.all(highest.map(size));
|
|
164
|
+
const highestTotal = knownTotal(highestSizes);
|
|
165
|
+
if (highestTotal !== null && highestTotal <= input.tryMaxBytes) {
|
|
166
|
+
return highest.map(({ item }) => item);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sized = await Promise.all(
|
|
170
|
+
groups.map(async (group) =>
|
|
171
|
+
Promise.all(group.map(async (candidate) => ({ ...candidate, bytes: await size(candidate) }))),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
const fitting = bestFitting(sized, input.tryMaxBytes);
|
|
175
|
+
return (fitting ?? groups.map((group) => group.at(-1) as TwitterCandidate)).map(({ item }) => item);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function knownTotal(sizes: Array<number | null>): number | null {
|
|
179
|
+
return sizes.some((size) => size === null) ? null : sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function bestFitting(
|
|
183
|
+
groups: Array<Array<TwitterCandidate & { bytes: number | null }>>,
|
|
184
|
+
maxBytes: number,
|
|
185
|
+
): TwitterCandidate[] | null {
|
|
186
|
+
let selections: Array<{ bytes: number; candidates: TwitterCandidate[]; quality: number }> = [
|
|
187
|
+
{ bytes: 0, candidates: [], quality: 0 },
|
|
188
|
+
];
|
|
189
|
+
for (const group of groups) {
|
|
190
|
+
selections = selections.flatMap((selection) =>
|
|
191
|
+
group.flatMap((candidate) => {
|
|
192
|
+
if (candidate.bytes === null || selection.bytes + candidate.bytes > maxBytes) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
return [{
|
|
196
|
+
bytes: selection.bytes + candidate.bytes,
|
|
197
|
+
candidates: [...selection.candidates, candidate],
|
|
198
|
+
quality: selection.quality + candidate.bitrate,
|
|
199
|
+
}];
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
if (selections.length === 0) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const best = selections.reduce((current, selection) => (selection.quality > current.quality ? selection : current));
|
|
207
|
+
return best.candidates;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function contentLength(net: Net, item: MediaItem): Promise<number | null> {
|
|
211
|
+
try {
|
|
212
|
+
const response = await net(item.url, { headers: item.headers, method: "HEAD" }, 1);
|
|
213
|
+
const raw = response.ok ? response.headers.get("content-length") : null;
|
|
214
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const bytes = Number(raw);
|
|
218
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
133
222
|
}
|
|
134
223
|
|
|
135
224
|
export function tweetId(input: string): string | null {
|