@postfetch/core 0.7.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 +9 -4
- package/dist/download.d.ts +16 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +267 -32
- package/dist/internal.d.ts +2 -0
- package/dist/mp4-remux.d.ts +18 -0
- package/dist/postfetch.d.ts +4 -2
- package/package.json +1 -1
- package/src/download.ts +43 -7
- package/src/facebook.ts +94 -7
- package/src/index.ts +1 -0
- package/src/internal.ts +2 -0
- package/src/mp4-remux.ts +100 -0
- package/src/postfetch.ts +8 -3
- package/src/twitter.ts +109 -20
package/README.md
CHANGED
|
@@ -13,12 +13,17 @@ 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 normalize an MP4 with an FFmpeg stream copy (fast-start,
|
|
20
|
+
non-negative timestamps, no edit list). Remuxing defaults to off, uses `ffmpeg`
|
|
21
|
+
from `PATH` unless `ffmpegPath` is set, and falls back to the original Blob on
|
|
22
|
+
failure. The legacy `(url, headers?, options?)` overload remains supported.
|
|
19
23
|
|
|
20
|
-
- `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap
|
|
24
|
+
- `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
25
|
- Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
|
|
22
|
-
- No runtime dependencies, no `
|
|
26
|
+
- No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
|
|
27
|
+
the opt-in `downloadBlob(..., { remux: true })` path is requested.
|
|
23
28
|
|
|
24
29
|
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,15 @@ 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
|
+
/** Headers required by the resolved media URL. */
|
|
12
|
+
headers?: HeadersInit;
|
|
13
|
+
/** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
|
|
14
|
+
remux?: boolean;
|
|
15
|
+
};
|
|
7
16
|
/** A zip archive produced by {@link archive}. */
|
|
8
17
|
export type Archive = {
|
|
9
18
|
/** The zip bytes (store mode, built in-process). */
|
|
@@ -35,20 +44,23 @@ export declare function download(item: MediaItem, options?: DownloadOptions): Pr
|
|
|
35
44
|
*
|
|
36
45
|
* This is useful for consumers that need to upload media themselves instead of
|
|
37
46
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
38
|
-
* required CDN headers, pass them
|
|
47
|
+
* required CDN headers, pass them in the options object. Set `remux: true` to
|
|
48
|
+
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
49
|
+
* remux fails, the original Blob is returned.
|
|
39
50
|
*
|
|
40
51
|
* @param url A direct media URL returned by a resolver.
|
|
41
|
-
* @param
|
|
42
|
-
* @param options Fetch options.
|
|
52
|
+
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
43
53
|
* @returns The downloaded media as a Blob.
|
|
44
54
|
*
|
|
45
55
|
* @example Upload a video with FormData
|
|
46
56
|
* ```ts
|
|
47
57
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
48
|
-
* const blob = await downloadBlob(media.url, media.headers);
|
|
58
|
+
* const blob = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
49
59
|
* form.append("video", blob, media.filename);
|
|
50
60
|
* ```
|
|
51
61
|
*/
|
|
62
|
+
export declare function downloadBlob(url: string, options?: DownloadBlobOptions): Promise<Blob>;
|
|
63
|
+
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
52
64
|
export declare function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
53
65
|
/**
|
|
54
66
|
* 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, } 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,83 @@ 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 remuxMp4(bytes, ffmpegPath = "ffmpeg", runtime) {
|
|
2518
|
+
let directory;
|
|
2519
|
+
let io;
|
|
2520
|
+
try {
|
|
2521
|
+
io = runtime ?? await nodeRuntime();
|
|
2522
|
+
directory = await io.makeTempDir("postfetch-remux-");
|
|
2523
|
+
const inputPath = `${directory}/input.mp4`;
|
|
2524
|
+
const outputPath = `${directory}/output.mp4`;
|
|
2525
|
+
await io.writeFile(inputPath, bytes);
|
|
2526
|
+
const result = await io.run(ffmpegPath, [
|
|
2527
|
+
"-nostdin",
|
|
2528
|
+
"-hide_banner",
|
|
2529
|
+
"-loglevel",
|
|
2530
|
+
"error",
|
|
2531
|
+
"-y",
|
|
2532
|
+
"-i",
|
|
2533
|
+
inputPath,
|
|
2534
|
+
"-map",
|
|
2535
|
+
"0:v:0",
|
|
2536
|
+
"-map",
|
|
2537
|
+
"0:a:0?",
|
|
2538
|
+
"-c",
|
|
2539
|
+
"copy",
|
|
2540
|
+
"-movflags",
|
|
2541
|
+
"+faststart",
|
|
2542
|
+
"-avoid_negative_ts",
|
|
2543
|
+
"make_zero",
|
|
2544
|
+
"-use_editlist",
|
|
2545
|
+
"0",
|
|
2546
|
+
outputPath
|
|
2547
|
+
]);
|
|
2548
|
+
if (!result.success) {
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
const output = await io.readFile(outputPath);
|
|
2552
|
+
return output.length > 0 ? output : null;
|
|
2553
|
+
} catch {
|
|
2554
|
+
return null;
|
|
2555
|
+
} finally {
|
|
2556
|
+
if (directory && io) {
|
|
2557
|
+
await io.removeDir(directory).catch(() => {
|
|
2558
|
+
return;
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
async function nodeRuntime() {
|
|
2564
|
+
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
2565
|
+
import("node:child_process"),
|
|
2566
|
+
import("node:fs/promises"),
|
|
2567
|
+
import("node:os"),
|
|
2568
|
+
import("node:path")
|
|
2569
|
+
]);
|
|
2570
|
+
return {
|
|
2571
|
+
makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
|
|
2572
|
+
readFile: async (path) => new Uint8Array(await readFile(path)),
|
|
2573
|
+
removeDir: async (path) => {
|
|
2574
|
+
await rm(path, { force: true, recursive: true });
|
|
2575
|
+
},
|
|
2576
|
+
run: (command, args) => new Promise((resolve2) => {
|
|
2577
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2578
|
+
let stderr = "";
|
|
2579
|
+
child.stderr?.on("data", (chunk) => {
|
|
2580
|
+
if (stderr.length < 4096) {
|
|
2581
|
+
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
child.once("error", (error) => resolve2({ stderr: error.message, success: false }));
|
|
2585
|
+
child.once("close", (code) => resolve2({ stderr, success: code === 0 }));
|
|
2586
|
+
}),
|
|
2587
|
+
writeFile: async (path, value) => {
|
|
2588
|
+
await writeFile(path, value);
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2373
2593
|
// src/remux.ts
|
|
2374
2594
|
function mergeAudioVideo(video, audio) {
|
|
2375
2595
|
const videoSource = analyze(video);
|
|
@@ -2605,13 +2825,28 @@ async function download(item, options = {}) {
|
|
|
2605
2825
|
}
|
|
2606
2826
|
return response;
|
|
2607
2827
|
}
|
|
2608
|
-
async function downloadBlob(url,
|
|
2828
|
+
async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
|
|
2829
|
+
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
2830
|
+
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
2831
|
+
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
2609
2832
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2610
2833
|
const response = await net(url, { headers });
|
|
2611
2834
|
if (!response.ok || !response.body) {
|
|
2612
2835
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
2613
2836
|
}
|
|
2614
|
-
|
|
2837
|
+
const blob = await response.blob();
|
|
2838
|
+
if (!modern || optionsOrHeaders.remux !== true) {
|
|
2839
|
+
return blob;
|
|
2840
|
+
}
|
|
2841
|
+
const remuxed = await remuxMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath);
|
|
2842
|
+
return remuxed ? new Blob([toArrayBuffer(remuxed)], { type: blob.type || "video/mp4" }) : blob;
|
|
2843
|
+
}
|
|
2844
|
+
function isDownloadBlobOptions(value) {
|
|
2845
|
+
if (value instanceof Headers || Array.isArray(value)) {
|
|
2846
|
+
return false;
|
|
2847
|
+
}
|
|
2848
|
+
const candidate = value;
|
|
2849
|
+
return "headers" in candidate && typeof candidate.headers !== "string" || typeof candidate.remux === "boolean" || typeof candidate.ffmpegPath === "string" || typeof candidate.fetch === "function";
|
|
2615
2850
|
}
|
|
2616
2851
|
async function archive(result, options = {}) {
|
|
2617
2852
|
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,18 @@
|
|
|
1
|
+
type CommandResult = {
|
|
2
|
+
stderr: string;
|
|
3
|
+
success: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type Mp4RemuxRuntime = {
|
|
6
|
+
makeTempDir(prefix: string): Promise<string>;
|
|
7
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
8
|
+
removeDir(path: string): Promise<void>;
|
|
9
|
+
run(command: string, args: string[]): Promise<CommandResult>;
|
|
10
|
+
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Normalize an MP4 with the same stream-copy remux used by UMMR. Encoded media
|
|
14
|
+
* is left untouched; FFmpeg only rebuilds the container and timing tables.
|
|
15
|
+
* Returns `null` when FFmpeg is unavailable or the input cannot be remuxed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function remuxMp4(bytes: Uint8Array, ffmpegPath?: string, runtime?: Mp4RemuxRuntime): Promise<Uint8Array | null>;
|
|
18
|
+
export {};
|
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.8.0",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
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). */
|
|
@@ -59,31 +70,56 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
|
|
|
59
70
|
*
|
|
60
71
|
* This is useful for consumers that need to upload media themselves instead of
|
|
61
72
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
62
|
-
* required CDN headers, pass them
|
|
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.
|
|
63
76
|
*
|
|
64
77
|
* @param url A direct media URL returned by a resolver.
|
|
65
|
-
* @param
|
|
66
|
-
* @param options Fetch options.
|
|
78
|
+
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
67
79
|
* @returns The downloaded media as a Blob.
|
|
68
80
|
*
|
|
69
81
|
* @example Upload a video with FormData
|
|
70
82
|
* ```ts
|
|
71
83
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
72
|
-
* const blob = await downloadBlob(media.url, media.headers);
|
|
84
|
+
* const blob = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
73
85
|
* form.append("video", blob, media.filename);
|
|
74
86
|
* ```
|
|
75
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>;
|
|
76
91
|
export async function downloadBlob(
|
|
77
92
|
url: string,
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
|
|
94
|
+
legacyOptions: DownloadOptions = {},
|
|
80
95
|
): Promise<Blob> {
|
|
96
|
+
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
97
|
+
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
98
|
+
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
81
99
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
82
100
|
const response = await net(url, { headers });
|
|
83
101
|
if (!response.ok || !response.body) {
|
|
84
102
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
85
103
|
}
|
|
86
|
-
|
|
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
|
+
);
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
/**
|
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,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
|
@@ -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 {
|