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