@postfetch/core 0.10.2 → 0.12.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/dist/index.d.ts +1 -1
- package/dist/index.js +208 -20
- package/dist/internal.d.ts +18 -0
- package/dist/postfetch.d.ts +2 -0
- package/package.json +1 -1
- package/src/facebook.ts +2 -1
- package/src/index.ts +1 -0
- package/src/instagram.ts +1 -1
- package/src/internal.ts +19 -0
- package/src/linkedin.ts +1 -0
- package/src/pinterest.ts +1 -1
- package/src/postfetch.ts +3 -0
- package/src/reddit.ts +90 -1
- package/src/soundcloud.ts +1 -1
- package/src/tiktok.ts +1 -1
- package/src/twitter.ts +92 -13
- package/src/youtube.ts +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -23,4 +23,4 @@ export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
|
23
23
|
export { buildAudioSliderVideo, type AudioSliderVideoOptions } from "./audio-slider";
|
|
24
24
|
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, type RemuxedVideo, } from "./download";
|
|
25
25
|
export { PostfetchError } from "./internal";
|
|
26
|
-
export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, TwitterQuotedTweet, YoutubeExtra, } from "./internal";
|
|
26
|
+
export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostComment, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, TwitterQuotedTweet, YoutubeExtra, } from "./internal";
|
package/dist/index.js
CHANGED
|
@@ -174,7 +174,7 @@ async function resolveFacebook(input) {
|
|
|
174
174
|
const url = embed ?? watch?.url ?? null;
|
|
175
175
|
if (!url) {
|
|
176
176
|
if (pageMetadata?.text) {
|
|
177
|
-
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
177
|
+
return { archiveFilename: filename(`facebook_${id}.zip`), comments: [], id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
178
178
|
}
|
|
179
179
|
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
180
180
|
}
|
|
@@ -189,6 +189,7 @@ async function resolveFacebook(input) {
|
|
|
189
189
|
};
|
|
190
190
|
return {
|
|
191
191
|
archiveFilename: filename(`facebook_${id}.zip`),
|
|
192
|
+
comments: [],
|
|
192
193
|
id,
|
|
193
194
|
items: [item],
|
|
194
195
|
metadata: mergeMetadata(pageMetadata, watch?.metadata),
|
|
@@ -385,7 +386,7 @@ async function resolveInstagram(input) {
|
|
|
385
386
|
if (items.length === 0) {
|
|
386
387
|
throw new Error("Instagram media url not found");
|
|
387
388
|
}
|
|
388
|
-
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
389
|
+
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
389
390
|
}
|
|
390
391
|
async function instagramUnavailable(net, code) {
|
|
391
392
|
const url = new URL("https://i.instagram.com/api/v1/oembed/");
|
|
@@ -804,6 +805,7 @@ async function resolveLinkedin(input) {
|
|
|
804
805
|
}
|
|
805
806
|
return {
|
|
806
807
|
archiveFilename: filename(`linkedin_${id}.zip`),
|
|
808
|
+
comments: [],
|
|
807
809
|
id,
|
|
808
810
|
items,
|
|
809
811
|
metadata: linkedinMetadata(post),
|
|
@@ -1323,7 +1325,7 @@ async function resolvePinterest(input) {
|
|
|
1323
1325
|
if (items.length === 0) {
|
|
1324
1326
|
throw new Error("Pinterest media not found");
|
|
1325
1327
|
}
|
|
1326
|
-
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
1328
|
+
return { archiveFilename: filename(`pinterest_${id}.zip`), comments: [], id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
1327
1329
|
}
|
|
1328
1330
|
function pinterestMetadata(pin) {
|
|
1329
1331
|
const pinner = object(pin.pinner) ? pin.pinner : null;
|
|
@@ -1538,7 +1540,7 @@ async function resolveReddit(input) {
|
|
|
1538
1540
|
const token = await appToken(input.net);
|
|
1539
1541
|
const post = await fetchPost(input.net, token, id);
|
|
1540
1542
|
const items = await mediaItems3(input, post, id);
|
|
1541
|
-
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
1543
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), comments: await redditComments(input, token, id), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
1542
1544
|
}
|
|
1543
1545
|
function redditMetadata(post) {
|
|
1544
1546
|
const author = string(post.author);
|
|
@@ -1788,6 +1790,102 @@ function extensionOf(mime) {
|
|
|
1788
1790
|
}
|
|
1789
1791
|
return "jpg";
|
|
1790
1792
|
}
|
|
1793
|
+
async function redditComments(input, token, id) {
|
|
1794
|
+
const limit = input.comments;
|
|
1795
|
+
if (!limit || !Number.isSafeInteger(limit) || limit < 0)
|
|
1796
|
+
return [];
|
|
1797
|
+
try {
|
|
1798
|
+
const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
|
|
1799
|
+
const response = await input.net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=${Math.min(limit, 100)}&depth=1&sort=top`, { headers }, 1);
|
|
1800
|
+
if (!response.ok)
|
|
1801
|
+
throw new Error("Reddit comments failed");
|
|
1802
|
+
const payload = await response.json();
|
|
1803
|
+
const listing = Array.isArray(payload) ? payload[1] : null;
|
|
1804
|
+
if (!object(listing) || !object(listing.data) || !Array.isArray(listing.data.children))
|
|
1805
|
+
throw new Error("Invalid Reddit comments");
|
|
1806
|
+
const comments = [];
|
|
1807
|
+
const seen = new Set;
|
|
1808
|
+
const queued = new Set;
|
|
1809
|
+
const pending = [];
|
|
1810
|
+
const collect = (things) => {
|
|
1811
|
+
for (const thing of things) {
|
|
1812
|
+
if (!object(thing) || !object(thing.data))
|
|
1813
|
+
continue;
|
|
1814
|
+
const data = thing.data;
|
|
1815
|
+
if (data.parent_id !== `t3_${id}`)
|
|
1816
|
+
continue;
|
|
1817
|
+
if (thing.kind === "more" && Array.isArray(data.children)) {
|
|
1818
|
+
for (const child of data.children) {
|
|
1819
|
+
if (typeof child !== "string" || !/^[a-z0-9]+$/i.test(child) || queued.has(child))
|
|
1820
|
+
continue;
|
|
1821
|
+
queued.add(child);
|
|
1822
|
+
pending.push(child);
|
|
1823
|
+
}
|
|
1824
|
+
} else if (thing.kind === "t1" && comments.length < limit) {
|
|
1825
|
+
const commentId = string(data.id);
|
|
1826
|
+
if (!commentId || seen.has(commentId))
|
|
1827
|
+
continue;
|
|
1828
|
+
seen.add(commentId);
|
|
1829
|
+
const body = string(data.body);
|
|
1830
|
+
if (!body || body === "[deleted]" || body === "[removed]")
|
|
1831
|
+
continue;
|
|
1832
|
+
comments.push(redditComment(data, commentId, id, body));
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
collect(listing.data.children);
|
|
1837
|
+
while (comments.length < limit && pending.length > 0) {
|
|
1838
|
+
const children = pending.splice(0, Math.min(100, limit - comments.length));
|
|
1839
|
+
const url = new URL("https://oauth.reddit.com/api/morechildren");
|
|
1840
|
+
url.search = new URLSearchParams({ api_type: "json", raw_json: "1", link_id: `t3_${id}`, children: children.join(","), sort: "top", depth: "1", limit_children: "true" }).toString();
|
|
1841
|
+
const more = await input.net(url.href, { headers }, 1);
|
|
1842
|
+
if (!more.ok)
|
|
1843
|
+
throw new Error("Reddit more comments failed");
|
|
1844
|
+
const result = await more.json();
|
|
1845
|
+
const json = object(result) && object(result.json) ? result.json : null;
|
|
1846
|
+
const data = json && object(json.data) ? json.data : null;
|
|
1847
|
+
if (!data || !Array.isArray(data.things) || Array.isArray(json?.errors) && json.errors.length > 0)
|
|
1848
|
+
throw new Error("Invalid additional Reddit comments");
|
|
1849
|
+
collect(data.things);
|
|
1850
|
+
}
|
|
1851
|
+
return comments;
|
|
1852
|
+
} catch {
|
|
1853
|
+
return [];
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function redditComment(data, id, postId2, body) {
|
|
1857
|
+
const metadata = object(data.media_metadata) ? data.media_metadata : {};
|
|
1858
|
+
const mediaIds = Object.keys(metadata).filter((key) => object(metadata[key])).sort((left, right) => {
|
|
1859
|
+
const position = (key) => {
|
|
1860
|
+
const meta = metadata[key];
|
|
1861
|
+
const best = object(meta) && object(meta.s) ? meta.s : {};
|
|
1862
|
+
const tokens = [``, ``, string(best.u), string(best.gif), string(best.mp4)];
|
|
1863
|
+
const positions = tokens.flatMap((token) => token && body.includes(token) ? [body.indexOf(token)] : []);
|
|
1864
|
+
return Math.min(...positions);
|
|
1865
|
+
};
|
|
1866
|
+
return position(left) - position(right);
|
|
1867
|
+
});
|
|
1868
|
+
const items = galleryItems({ media_metadata: metadata, gallery_data: { items: mediaIds.map((media_id) => ({ media_id })) } }, id);
|
|
1869
|
+
const text = body.replace(/!\[(?:img|gif)\]\(([^)]+)\)/g, (match, key) => {
|
|
1870
|
+
const meta = metadata[key];
|
|
1871
|
+
return object(meta) && object(meta.s) && (string(meta.s.u) || string(meta.s.gif) || string(meta.s.mp4)) ? "" : match;
|
|
1872
|
+
}).split(`
|
|
1873
|
+
`).filter((line) => !items.some((item) => line.trim() === item.url)).join(`
|
|
1874
|
+
`).trim();
|
|
1875
|
+
const author = string(data.author);
|
|
1876
|
+
return {
|
|
1877
|
+
id,
|
|
1878
|
+
url: `https://www.reddit.com/comments/${postId2}/_/${id}/`,
|
|
1879
|
+
items,
|
|
1880
|
+
metadata: {
|
|
1881
|
+
text,
|
|
1882
|
+
author: author && author !== "[deleted]" ? { handle: author } : undefined,
|
|
1883
|
+
createdAt: isoFromEpochSeconds(data.created_utc),
|
|
1884
|
+
likeCount: count(data.ups),
|
|
1885
|
+
nsfw: bool(data.over_18)
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1791
1889
|
|
|
1792
1890
|
// src/soundcloud.ts
|
|
1793
1891
|
async function resolveSoundcloud(input) {
|
|
@@ -1796,7 +1894,7 @@ async function resolveSoundcloud(input) {
|
|
|
1796
1894
|
const track = await resolveTrack(input.net, url, clientId);
|
|
1797
1895
|
const id = trackId(track);
|
|
1798
1896
|
const item = await audioItem(input.net, track, id, clientId);
|
|
1799
|
-
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
1897
|
+
return { archiveFilename: filename(`soundcloud_${id}.zip`), comments: [], id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
1800
1898
|
}
|
|
1801
1899
|
function soundcloudMetadata(track) {
|
|
1802
1900
|
const user = object(track.user) ? track.user : null;
|
|
@@ -1932,7 +2030,7 @@ async function resolveTiktok(input) {
|
|
|
1932
2030
|
if (items.length === 0) {
|
|
1933
2031
|
throw new Error("TikTok media not found");
|
|
1934
2032
|
}
|
|
1935
|
-
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
2033
|
+
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), comments: [], id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
1936
2034
|
}
|
|
1937
2035
|
async function videoPage(net, id, photo) {
|
|
1938
2036
|
for (let attempt = 0;attempt < pageAttemptCount; attempt += 1) {
|
|
@@ -2194,7 +2292,7 @@ async function resolveTwitter(input) {
|
|
|
2194
2292
|
});
|
|
2195
2293
|
});
|
|
2196
2294
|
const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
|
|
2197
|
-
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
2295
|
+
return { archiveFilename: filename(`twitter_${id}.zip`), comments: await twitterComments(input, id), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
2198
2296
|
}
|
|
2199
2297
|
function twitterMetadata(tweet) {
|
|
2200
2298
|
const user = object(tweet.user) ? tweet.user : null;
|
|
@@ -2208,6 +2306,14 @@ function twitterMetadata(tweet) {
|
|
|
2208
2306
|
if (quotedTweet && quotedId) {
|
|
2209
2307
|
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
2210
2308
|
}
|
|
2309
|
+
const parent = object(tweet.parent) ? tweet.parent : null;
|
|
2310
|
+
const parentId = parent ? string(parent.id_str) : null;
|
|
2311
|
+
const replyToId = string(tweet.in_reply_to_status_id_str) ?? parentId;
|
|
2312
|
+
if (replyToId)
|
|
2313
|
+
extra.replyToId = replyToId;
|
|
2314
|
+
if (parent && parentId) {
|
|
2315
|
+
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
2316
|
+
}
|
|
2211
2317
|
return {
|
|
2212
2318
|
text: twitterText(tweet) ?? undefined,
|
|
2213
2319
|
author: user ? {
|
|
@@ -2240,7 +2346,7 @@ function needsFullText(tweet) {
|
|
|
2240
2346
|
return object(tweet.note_tweet) || bool(tweet.truncated) === true || (string(tweet.text)?.length ?? 0) >= 270;
|
|
2241
2347
|
}
|
|
2242
2348
|
async function expandTwitterText(net, tweet, rootId) {
|
|
2243
|
-
const posts = new Map(twitterTweets(tweet, rootId).map(({ id, tweet: post }) => [id, post]));
|
|
2349
|
+
const posts = new Map(twitterTweets(tweet, rootId, true).map(({ id, tweet: post }) => [id, post]));
|
|
2244
2350
|
for (const [id, post] of posts) {
|
|
2245
2351
|
if (!needsFullText(post)) {
|
|
2246
2352
|
continue;
|
|
@@ -2275,21 +2381,21 @@ async function expandTwitterText(net, tweet, rootId) {
|
|
|
2275
2381
|
} catch {}
|
|
2276
2382
|
}
|
|
2277
2383
|
}
|
|
2278
|
-
function twitterTweets(tweet, rootId) {
|
|
2384
|
+
function twitterTweets(tweet, rootId, includeParents = false) {
|
|
2279
2385
|
const tweets = [];
|
|
2280
2386
|
const seen = new Set;
|
|
2281
|
-
|
|
2282
|
-
let fallbackId = rootId;
|
|
2283
|
-
while (current) {
|
|
2387
|
+
const visit = (current, fallbackId) => {
|
|
2284
2388
|
const id = string(current.id_str) ?? fallbackId;
|
|
2285
|
-
if (!id || seen.has(id))
|
|
2286
|
-
|
|
2287
|
-
}
|
|
2288
|
-
tweets.push({ id, tweet: current });
|
|
2389
|
+
if (!id || seen.has(id))
|
|
2390
|
+
return;
|
|
2289
2391
|
seen.add(id);
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2392
|
+
tweets.push({ id, tweet: current });
|
|
2393
|
+
if (object(current.quoted_tweet))
|
|
2394
|
+
visit(current.quoted_tweet);
|
|
2395
|
+
if (includeParents && object(current.parent))
|
|
2396
|
+
visit(current.parent);
|
|
2397
|
+
};
|
|
2398
|
+
visit(tweet, rootId);
|
|
2293
2399
|
return tweets;
|
|
2294
2400
|
}
|
|
2295
2401
|
async function syndication(net, id) {
|
|
@@ -2397,6 +2503,87 @@ async function contentLength(net, item) {
|
|
|
2397
2503
|
function tweetId(input) {
|
|
2398
2504
|
return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
|
|
2399
2505
|
}
|
|
2506
|
+
async function twitterComments(input, id) {
|
|
2507
|
+
const limit = input.comments;
|
|
2508
|
+
if (!limit || !Number.isSafeInteger(limit) || limit < 0)
|
|
2509
|
+
return [];
|
|
2510
|
+
try {
|
|
2511
|
+
const comments = [];
|
|
2512
|
+
const seen = new Set;
|
|
2513
|
+
const cursors = new Set;
|
|
2514
|
+
let cursor = null;
|
|
2515
|
+
do {
|
|
2516
|
+
const url = new URL(`https://api.fxtwitter.com/2/conversation/${id}`);
|
|
2517
|
+
if (cursor)
|
|
2518
|
+
url.searchParams.set("cursor", cursor);
|
|
2519
|
+
const response = await input.net(url.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } }, 1);
|
|
2520
|
+
if (!response.ok)
|
|
2521
|
+
throw new Error("Comment lookup failed");
|
|
2522
|
+
const payload = await response.json();
|
|
2523
|
+
if (!object(payload) || payload.code !== 200 || !Array.isArray(payload.replies))
|
|
2524
|
+
throw new Error("Invalid comments");
|
|
2525
|
+
const previousCount = comments.length;
|
|
2526
|
+
for (const reply of payload.replies) {
|
|
2527
|
+
if (!object(reply))
|
|
2528
|
+
continue;
|
|
2529
|
+
const replyId = string(reply.id);
|
|
2530
|
+
const parent = object(reply.replying_to) ? string(reply.replying_to.status) : null;
|
|
2531
|
+
if (!replyId || parent !== id || seen.has(replyId))
|
|
2532
|
+
continue;
|
|
2533
|
+
seen.add(replyId);
|
|
2534
|
+
comments.push(twitterComment(reply, replyId));
|
|
2535
|
+
if (comments.length >= limit)
|
|
2536
|
+
return comments;
|
|
2537
|
+
}
|
|
2538
|
+
cursor = object(payload.cursor) ? string(payload.cursor.bottom) : null;
|
|
2539
|
+
if (!cursor || cursors.has(cursor) || comments.length === previousCount)
|
|
2540
|
+
break;
|
|
2541
|
+
cursors.add(cursor);
|
|
2542
|
+
} while (comments.length < limit);
|
|
2543
|
+
return comments;
|
|
2544
|
+
} catch {
|
|
2545
|
+
return [];
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
function twitterComment(reply, id) {
|
|
2549
|
+
const author2 = object(reply.author) ? reply.author : {};
|
|
2550
|
+
const verification = object(author2.verification) ? author2.verification : {};
|
|
2551
|
+
const media = object(reply.media) && Array.isArray(reply.media.all) ? reply.media.all.filter(object) : [];
|
|
2552
|
+
const items = media.flatMap((entry, index) => {
|
|
2553
|
+
const url = string(entry.url);
|
|
2554
|
+
const photo = entry.type === "photo";
|
|
2555
|
+
if (!url || !photo && entry.type !== "video" && entry.type !== "gif" && entry.type !== "animated_gif")
|
|
2556
|
+
return [];
|
|
2557
|
+
return [{
|
|
2558
|
+
id,
|
|
2559
|
+
platform: "twitter",
|
|
2560
|
+
filename: filename(`twitter_${id}_${index + 1}.${photo ? "jpg" : "mp4"}`),
|
|
2561
|
+
kind: photo ? "image" : "video",
|
|
2562
|
+
mime: photo ? "image/jpeg" : "video/mp4",
|
|
2563
|
+
url,
|
|
2564
|
+
headers: { "user-agent": browserUserAgent() }
|
|
2565
|
+
}];
|
|
2566
|
+
});
|
|
2567
|
+
return {
|
|
2568
|
+
id,
|
|
2569
|
+
url: `https://x.com/i/status/${id}`,
|
|
2570
|
+
items,
|
|
2571
|
+
metadata: {
|
|
2572
|
+
text: string(reply.text) ?? undefined,
|
|
2573
|
+
author: {
|
|
2574
|
+
handle: string(author2.screen_name) ?? undefined,
|
|
2575
|
+
name: string(author2.name) ?? undefined,
|
|
2576
|
+
verified: bool(verification.verified)
|
|
2577
|
+
},
|
|
2578
|
+
createdAt: isoFromDateString(reply.created_at),
|
|
2579
|
+
likeCount: count(reply.likes),
|
|
2580
|
+
commentCount: count(reply.replies),
|
|
2581
|
+
shareCount: count(reply.reposts),
|
|
2582
|
+
viewCount: count(reply.views),
|
|
2583
|
+
nsfw: bool(reply.possibly_sensitive)
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2400
2587
|
|
|
2401
2588
|
// src/youtube.ts
|
|
2402
2589
|
var androidVrClient = {
|
|
@@ -2470,7 +2657,7 @@ function youtubeResult(id, payload, streams, headers) {
|
|
|
2470
2657
|
...streams.hls ? { hls: true } : {},
|
|
2471
2658
|
...streams.audio ? { audio: { headers, url: streams.audio } } : {}
|
|
2472
2659
|
};
|
|
2473
|
-
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
2660
|
+
return { archiveFilename: filename(`youtube_${id}.zip`), comments: [], id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
2474
2661
|
}
|
|
2475
2662
|
async function probeStream(net, url, headers) {
|
|
2476
2663
|
const response = await net(url, { headers }, 1);
|
|
@@ -2702,6 +2889,7 @@ async function postfetch(url, options = {}) {
|
|
|
2702
2889
|
}
|
|
2703
2890
|
const context = {
|
|
2704
2891
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
2892
|
+
comments: options.comments,
|
|
2705
2893
|
preferredWidth: options.preferredWidth ?? 720,
|
|
2706
2894
|
tryMaxBytes: options.tryMaxBytes,
|
|
2707
2895
|
url: trimmed
|
package/dist/internal.d.ts
CHANGED
|
@@ -97,10 +97,14 @@ export type TiktokExtra = {
|
|
|
97
97
|
};
|
|
98
98
|
/** X (Twitter)-specific metadata. */
|
|
99
99
|
export type TwitterExtra = {
|
|
100
|
+
/** Parent status id when this post is a reply, even if the parent metadata is unavailable. */
|
|
101
|
+
replyToId?: string;
|
|
100
102
|
/** BCP 47 language tag detected for the tweet. */
|
|
101
103
|
lang?: string;
|
|
102
104
|
/** The post quoted by this post, when X includes it in the resolved payload. */
|
|
103
105
|
quotedTweet?: TwitterQuotedTweet;
|
|
106
|
+
/** The parent this post replies to, when included by X. Media is not collected. */
|
|
107
|
+
parentTweet?: TwitterQuotedTweet;
|
|
104
108
|
};
|
|
105
109
|
/** A quoted X post and its normalized metadata. */
|
|
106
110
|
export type TwitterQuotedTweet = {
|
|
@@ -147,12 +151,25 @@ export type PinterestExtra = {
|
|
|
147
151
|
/** Outbound link the pin points to, when set. */
|
|
148
152
|
outboundLink?: string;
|
|
149
153
|
};
|
|
154
|
+
/** A reply with its own metadata and downloadable media, separate from the main post. */
|
|
155
|
+
export type PostComment = {
|
|
156
|
+
/** Reply post id. */
|
|
157
|
+
id: string;
|
|
158
|
+
/** Permalink to the reply. */
|
|
159
|
+
url: string;
|
|
160
|
+
/** Normalized reply metadata. */
|
|
161
|
+
metadata: PostMetadata;
|
|
162
|
+
/** Attachments belonging to this reply. */
|
|
163
|
+
items: MediaItem[];
|
|
164
|
+
};
|
|
150
165
|
/** The result of resolving a post: its platform, id, media items and metadata. */
|
|
151
166
|
export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
|
|
152
167
|
/** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
|
|
153
168
|
export type PlatformResult<P extends Platform, Extra> = {
|
|
154
169
|
/** Suggested filename when zipping every item. */
|
|
155
170
|
archiveFilename: string;
|
|
171
|
+
/** Requested replies, or an empty array when not requested, unsupported or unavailable. */
|
|
172
|
+
comments: PostComment[];
|
|
156
173
|
/** Platform post/media id. */
|
|
157
174
|
id: string;
|
|
158
175
|
/** The post's media, in order. */
|
|
@@ -169,6 +186,7 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
|
|
|
169
186
|
export type ResolveContext = {
|
|
170
187
|
net: Net;
|
|
171
188
|
preferredWidth: number;
|
|
189
|
+
comments?: number;
|
|
172
190
|
/** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
|
|
173
191
|
tryMaxBytes?: number;
|
|
174
192
|
url: string;
|
package/dist/postfetch.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { type Platform, type PostfetchResult } from "./internal";
|
|
|
3
3
|
export type PostfetchOptions = {
|
|
4
4
|
/** Custom `fetch` implementation — inject one to unit-test resolvers offline. Defaults to the global `fetch`. */
|
|
5
5
|
fetch?: typeof fetch;
|
|
6
|
+
/** Maximum direct comments to fetch for X and Reddit. Other platforms ignore this option. Failures return an empty array. */
|
|
7
|
+
comments?: number;
|
|
6
8
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
7
9
|
preferredWidth?: number;
|
|
8
10
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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/facebook.ts
CHANGED
|
@@ -16,7 +16,7 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
|
|
|
16
16
|
// has no downloadable media. Require post metadata from the canonical page
|
|
17
17
|
// so a missing or login-walled video does not become a false success.
|
|
18
18
|
if (pageMetadata?.text) {
|
|
19
|
-
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
19
|
+
return { archiveFilename: filename(`facebook_${id}.zip`), comments: [], id, items: [], metadata: pageMetadata, platform: "facebook" };
|
|
20
20
|
}
|
|
21
21
|
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
22
22
|
}
|
|
@@ -31,6 +31,7 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
|
|
|
31
31
|
};
|
|
32
32
|
return {
|
|
33
33
|
archiveFilename: filename(`facebook_${id}.zip`),
|
|
34
|
+
comments: [],
|
|
34
35
|
id,
|
|
35
36
|
items: [item],
|
|
36
37
|
metadata: mergeMetadata(pageMetadata, watch?.metadata),
|
package/src/index.ts
CHANGED
package/src/instagram.ts
CHANGED
|
@@ -80,7 +80,7 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
80
80
|
if (items.length === 0) {
|
|
81
81
|
throw new Error("Instagram media url not found");
|
|
82
82
|
}
|
|
83
|
-
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
83
|
+
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
async function instagramUnavailable(net: Net, code: string): Promise<PostfetchError> {
|
package/src/internal.ts
CHANGED
|
@@ -101,10 +101,14 @@ export type TiktokExtra = {
|
|
|
101
101
|
|
|
102
102
|
/** X (Twitter)-specific metadata. */
|
|
103
103
|
export type TwitterExtra = {
|
|
104
|
+
/** Parent status id when this post is a reply, even if the parent metadata is unavailable. */
|
|
105
|
+
replyToId?: string;
|
|
104
106
|
/** BCP 47 language tag detected for the tweet. */
|
|
105
107
|
lang?: string;
|
|
106
108
|
/** The post quoted by this post, when X includes it in the resolved payload. */
|
|
107
109
|
quotedTweet?: TwitterQuotedTweet;
|
|
110
|
+
/** The parent this post replies to, when included by X. Media is not collected. */
|
|
111
|
+
parentTweet?: TwitterQuotedTweet;
|
|
108
112
|
};
|
|
109
113
|
|
|
110
114
|
/** A quoted X post and its normalized metadata. */
|
|
@@ -155,6 +159,18 @@ export type PinterestExtra = {
|
|
|
155
159
|
outboundLink?: string;
|
|
156
160
|
};
|
|
157
161
|
|
|
162
|
+
/** A reply with its own metadata and downloadable media, separate from the main post. */
|
|
163
|
+
export type PostComment = {
|
|
164
|
+
/** Reply post id. */
|
|
165
|
+
id: string;
|
|
166
|
+
/** Permalink to the reply. */
|
|
167
|
+
url: string;
|
|
168
|
+
/** Normalized reply metadata. */
|
|
169
|
+
metadata: PostMetadata;
|
|
170
|
+
/** Attachments belonging to this reply. */
|
|
171
|
+
items: MediaItem[];
|
|
172
|
+
};
|
|
173
|
+
|
|
158
174
|
/** The result of resolving a post: its platform, id, media items and metadata. */
|
|
159
175
|
export type PostfetchResult =
|
|
160
176
|
| PlatformResult<"facebook", never>
|
|
@@ -171,6 +187,8 @@ export type PostfetchResult =
|
|
|
171
187
|
export type PlatformResult<P extends Platform, Extra> = {
|
|
172
188
|
/** Suggested filename when zipping every item. */
|
|
173
189
|
archiveFilename: string;
|
|
190
|
+
/** Requested replies, or an empty array when not requested, unsupported or unavailable. */
|
|
191
|
+
comments: PostComment[];
|
|
174
192
|
/** Platform post/media id. */
|
|
175
193
|
id: string;
|
|
176
194
|
/** The post's media, in order. */
|
|
@@ -189,6 +207,7 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
|
|
|
189
207
|
export type ResolveContext = {
|
|
190
208
|
net: Net;
|
|
191
209
|
preferredWidth: number;
|
|
210
|
+
comments?: number;
|
|
192
211
|
/** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
|
|
193
212
|
tryMaxBytes?: number;
|
|
194
213
|
url: string;
|
package/src/linkedin.ts
CHANGED
package/src/pinterest.ts
CHANGED
|
@@ -24,7 +24,7 @@ export async function resolvePinterest(input: ResolveContext): Promise<Postfetch
|
|
|
24
24
|
if (items.length === 0) {
|
|
25
25
|
throw new Error("Pinterest media not found");
|
|
26
26
|
}
|
|
27
|
-
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
27
|
+
return { archiveFilename: filename(`pinterest_${id}.zip`), comments: [], id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function pinterestMetadata(pin: Json): PostMetadata & { extra?: PinterestExtra } {
|
package/src/postfetch.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { resolveYoutube } from "./youtube";
|
|
|
13
13
|
export type PostfetchOptions = {
|
|
14
14
|
/** Custom `fetch` implementation — inject one to unit-test resolvers offline. Defaults to the global `fetch`. */
|
|
15
15
|
fetch?: typeof fetch;
|
|
16
|
+
/** Maximum direct comments to fetch for X and Reddit. Other platforms ignore this option. Failures return an empty array. */
|
|
17
|
+
comments?: number;
|
|
16
18
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
17
19
|
preferredWidth?: number;
|
|
18
20
|
/**
|
|
@@ -58,6 +60,7 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
|
|
|
58
60
|
}
|
|
59
61
|
const context: ResolveContext = {
|
|
60
62
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
63
|
+
comments: options.comments,
|
|
61
64
|
preferredWidth: options.preferredWidth ?? 720,
|
|
62
65
|
tryMaxBytes: options.tryMaxBytes,
|
|
63
66
|
url: trimmed,
|
package/src/reddit.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
object,
|
|
9
9
|
string,
|
|
10
10
|
type PostMetadata,
|
|
11
|
+
type PostComment,
|
|
11
12
|
type RedditExtra,
|
|
12
13
|
type ResolveContext,
|
|
13
14
|
type Json,
|
|
@@ -31,7 +32,7 @@ export async function resolveReddit(input: ResolveContext): Promise<PostfetchRes
|
|
|
31
32
|
// already thrown if the post itself is missing, so empty items here mean a
|
|
32
33
|
// self/text post, not a failure.
|
|
33
34
|
const items = await mediaItems(input, post, id);
|
|
34
|
-
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
35
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), comments: await redditComments(input, token, id), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function redditMetadata(post: Json): PostMetadata & { extra?: RedditExtra } {
|
|
@@ -316,3 +317,91 @@ function extensionOf(mime: string): string {
|
|
|
316
317
|
}
|
|
317
318
|
return "jpg";
|
|
318
319
|
}
|
|
320
|
+
|
|
321
|
+
async function redditComments(input: ResolveContext, token: string, id: string): Promise<PostComment[]> {
|
|
322
|
+
const limit = input.comments;
|
|
323
|
+
if (!limit || !Number.isSafeInteger(limit) || limit < 0) return [];
|
|
324
|
+
try {
|
|
325
|
+
const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
|
|
326
|
+
const response = await input.net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=${Math.min(limit, 100)}&depth=1&sort=top`, { headers }, 1);
|
|
327
|
+
if (!response.ok) throw new Error("Reddit comments failed");
|
|
328
|
+
const payload: unknown = await response.json();
|
|
329
|
+
const listing = Array.isArray(payload) ? payload[1] : null;
|
|
330
|
+
if (!object(listing) || !object(listing.data) || !Array.isArray(listing.data.children)) throw new Error("Invalid Reddit comments");
|
|
331
|
+
const comments: PostComment[] = [];
|
|
332
|
+
const seen = new Set<string>();
|
|
333
|
+
const queued = new Set<string>();
|
|
334
|
+
const pending: string[] = [];
|
|
335
|
+
const collect = (things: unknown[]) => {
|
|
336
|
+
for (const thing of things) {
|
|
337
|
+
if (!object(thing) || !object(thing.data)) continue;
|
|
338
|
+
const data = thing.data;
|
|
339
|
+
if (data.parent_id !== `t3_${id}`) continue;
|
|
340
|
+
if (thing.kind === "more" && Array.isArray(data.children)) {
|
|
341
|
+
for (const child of data.children) {
|
|
342
|
+
if (typeof child !== "string" || !/^[a-z0-9]+$/i.test(child) || queued.has(child)) continue;
|
|
343
|
+
queued.add(child);
|
|
344
|
+
pending.push(child);
|
|
345
|
+
}
|
|
346
|
+
} else if (thing.kind === "t1" && comments.length < limit) {
|
|
347
|
+
const commentId = string(data.id);
|
|
348
|
+
if (!commentId || seen.has(commentId)) continue;
|
|
349
|
+
seen.add(commentId);
|
|
350
|
+
const body = string(data.body);
|
|
351
|
+
if (!body || body === "[deleted]" || body === "[removed]") continue;
|
|
352
|
+
comments.push(redditComment(data, commentId, id, body));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
collect(listing.data.children);
|
|
357
|
+
while (comments.length < limit && pending.length > 0) {
|
|
358
|
+
const children = pending.splice(0, Math.min(100, limit - comments.length));
|
|
359
|
+
const url = new URL("https://oauth.reddit.com/api/morechildren");
|
|
360
|
+
url.search = new URLSearchParams({ api_type: "json", raw_json: "1", link_id: `t3_${id}`, children: children.join(","), sort: "top", depth: "1", limit_children: "true" }).toString();
|
|
361
|
+
const more = await input.net(url.href, { headers }, 1);
|
|
362
|
+
if (!more.ok) throw new Error("Reddit more comments failed");
|
|
363
|
+
const result: unknown = await more.json();
|
|
364
|
+
const json = object(result) && object(result.json) ? result.json : null;
|
|
365
|
+
const data = json && object(json.data) ? json.data : null;
|
|
366
|
+
if (!data || !Array.isArray(data.things) || (Array.isArray(json?.errors) && json.errors.length > 0)) throw new Error("Invalid additional Reddit comments");
|
|
367
|
+
collect(data.things);
|
|
368
|
+
}
|
|
369
|
+
return comments;
|
|
370
|
+
} catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function redditComment(data: Json, id: string, postId: string, body: string): PostComment {
|
|
376
|
+
const metadata = object(data.media_metadata) ? data.media_metadata : {};
|
|
377
|
+
// Comment images/GIFs use the same rendition metadata as Reddit galleries.
|
|
378
|
+
// Keep their inline order; do not treat arbitrary links as attachments.
|
|
379
|
+
const mediaIds = Object.keys(metadata).filter((key) => object(metadata[key])).sort((left, right) => {
|
|
380
|
+
const position = (key: string) => {
|
|
381
|
+
const meta = metadata[key];
|
|
382
|
+
const best = object(meta) && object(meta.s) ? meta.s : {};
|
|
383
|
+
const tokens = [``, ``, string(best.u), string(best.gif), string(best.mp4)];
|
|
384
|
+
const positions = tokens.flatMap((token) => token && body.includes(token) ? [body.indexOf(token)] : []);
|
|
385
|
+
return Math.min(...positions);
|
|
386
|
+
};
|
|
387
|
+
return position(left) - position(right);
|
|
388
|
+
});
|
|
389
|
+
const items = galleryItems({ media_metadata: metadata, gallery_data: { items: mediaIds.map((media_id) => ({ media_id })) } }, id);
|
|
390
|
+
const text = body.replace(/!\[(?:img|gif)\]\(([^)]+)\)/g, (match, key: string) => {
|
|
391
|
+
const meta = metadata[key];
|
|
392
|
+
return object(meta) && object(meta.s) && (string(meta.s.u) || string(meta.s.gif) || string(meta.s.mp4)) ? "" : match;
|
|
393
|
+
}).split("\n").filter((line) => !items.some((item) => line.trim() === item.url)).join("\n").trim();
|
|
394
|
+
const author = string(data.author);
|
|
395
|
+
return {
|
|
396
|
+
id,
|
|
397
|
+
url: `https://www.reddit.com/comments/${postId}/_/${id}/`,
|
|
398
|
+
items,
|
|
399
|
+
metadata: {
|
|
400
|
+
text,
|
|
401
|
+
author: author && author !== "[deleted]" ? { handle: author } : undefined,
|
|
402
|
+
createdAt: isoFromEpochSeconds(data.created_utc),
|
|
403
|
+
likeCount: count(data.ups),
|
|
404
|
+
nsfw: bool(data.over_18),
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
package/src/soundcloud.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function resolveSoundcloud(input: ResolveContext): Promise<Postfetc
|
|
|
22
22
|
const track = await resolveTrack(input.net, url, clientId);
|
|
23
23
|
const id = trackId(track);
|
|
24
24
|
const item = await audioItem(input.net, track, id, clientId);
|
|
25
|
-
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
25
|
+
return { archiveFilename: filename(`soundcloud_${id}.zip`), comments: [], id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function soundcloudMetadata(track: Json): PostMetadata & { extra?: SoundcloudExtra } {
|
package/src/tiktok.ts
CHANGED
|
@@ -42,7 +42,7 @@ export async function resolveTiktok(input: ResolveContext): Promise<PostfetchRes
|
|
|
42
42
|
if (items.length === 0) {
|
|
43
43
|
throw new Error("TikTok media not found");
|
|
44
44
|
}
|
|
45
|
-
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
45
|
+
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), comments: [], id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function videoPage(
|
package/src/twitter.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
string,
|
|
10
10
|
type PostMetadata,
|
|
11
11
|
type TwitterExtra,
|
|
12
|
+
type PostComment,
|
|
12
13
|
type ResolveContext,
|
|
13
14
|
type Json,
|
|
14
15
|
type Net,
|
|
@@ -43,7 +44,7 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
43
44
|
// A text-only tweet is a valid result with metadata and no media. Syndication
|
|
44
45
|
// has already failed or returned a tombstone when the tweet is unavailable,
|
|
45
46
|
// so an empty item list here does not mean the lookup failed.
|
|
46
|
-
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
47
|
+
return { archiveFilename: filename(`twitter_${id}.zip`), comments: await twitterComments(input, id), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterExtra } {
|
|
@@ -58,6 +59,13 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
58
59
|
if (quotedTweet && quotedId) {
|
|
59
60
|
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
60
61
|
}
|
|
62
|
+
const parent = object(tweet.parent) ? tweet.parent : null;
|
|
63
|
+
const parentId = parent ? string(parent.id_str) : null;
|
|
64
|
+
const replyToId = string(tweet.in_reply_to_status_id_str) ?? parentId;
|
|
65
|
+
if (replyToId) extra.replyToId = replyToId;
|
|
66
|
+
if (parent && parentId) {
|
|
67
|
+
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
68
|
+
}
|
|
61
69
|
return {
|
|
62
70
|
text: twitterText(tweet) ?? undefined,
|
|
63
71
|
author: user
|
|
@@ -98,7 +106,7 @@ function needsFullText(tweet: Json): boolean {
|
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
async function expandTwitterText(net: Net, tweet: Json, rootId: string): Promise<void> {
|
|
101
|
-
const posts = new Map(twitterTweets(tweet, rootId).map(({ id, tweet: post }) => [id, post]));
|
|
109
|
+
const posts = new Map(twitterTweets(tweet, rootId, true).map(({ id, tweet: post }) => [id, post]));
|
|
102
110
|
for (const [id, post] of posts) {
|
|
103
111
|
if (!needsFullText(post)) {
|
|
104
112
|
continue;
|
|
@@ -138,21 +146,18 @@ async function expandTwitterText(net: Net, tweet: Json, rootId: string): Promise
|
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
function twitterTweets(tweet: Json, rootId: string): Array<{ id: string; tweet: Json }> {
|
|
149
|
+
function twitterTweets(tweet: Json, rootId: string, includeParents = false): Array<{ id: string; tweet: Json }> {
|
|
142
150
|
const tweets: Array<{ id: string; tweet: Json }> = [];
|
|
143
151
|
const seen = new Set<string>();
|
|
144
|
-
|
|
145
|
-
let fallbackId: string | null = rootId;
|
|
146
|
-
while (current) {
|
|
152
|
+
const visit = (current: Json, fallbackId?: string): void => {
|
|
147
153
|
const id = string(current.id_str) ?? fallbackId;
|
|
148
|
-
if (!id || seen.has(id))
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
tweets.push({ id, tweet: current });
|
|
154
|
+
if (!id || seen.has(id)) return;
|
|
152
155
|
seen.add(id);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
tweets.push({ id, tweet: current });
|
|
157
|
+
if (object(current.quoted_tweet)) visit(current.quoted_tweet);
|
|
158
|
+
if (includeParents && object(current.parent)) visit(current.parent);
|
|
159
|
+
};
|
|
160
|
+
visit(tweet, rootId);
|
|
156
161
|
return tweets;
|
|
157
162
|
}
|
|
158
163
|
|
|
@@ -286,3 +291,77 @@ async function contentLength(net: Net, item: MediaItem): Promise<number | null>
|
|
|
286
291
|
export function tweetId(input: string): string | null {
|
|
287
292
|
return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
|
|
288
293
|
}
|
|
294
|
+
|
|
295
|
+
async function twitterComments(input: ResolveContext, id: string): Promise<PostComment[]> {
|
|
296
|
+
const limit = input.comments;
|
|
297
|
+
if (!limit || !Number.isSafeInteger(limit) || limit < 0) return [];
|
|
298
|
+
try {
|
|
299
|
+
const comments: PostComment[] = [];
|
|
300
|
+
const seen = new Set<string>();
|
|
301
|
+
const cursors = new Set<string>();
|
|
302
|
+
let cursor: string | null = null;
|
|
303
|
+
do {
|
|
304
|
+
const url = new URL(`https://api.fxtwitter.com/2/conversation/${id}`);
|
|
305
|
+
if (cursor) url.searchParams.set("cursor", cursor);
|
|
306
|
+
const response = await input.net(url.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } }, 1);
|
|
307
|
+
if (!response.ok) throw new Error("Comment lookup failed");
|
|
308
|
+
const payload: unknown = await response.json();
|
|
309
|
+
if (!object(payload) || payload.code !== 200 || !Array.isArray(payload.replies)) throw new Error("Invalid comments");
|
|
310
|
+
const previousCount = comments.length;
|
|
311
|
+
for (const reply of payload.replies) {
|
|
312
|
+
if (!object(reply)) continue;
|
|
313
|
+
const replyId = string(reply.id);
|
|
314
|
+
const parent = object(reply.replying_to) ? string(reply.replying_to.status) : null;
|
|
315
|
+
if (!replyId || parent !== id || seen.has(replyId)) continue;
|
|
316
|
+
seen.add(replyId);
|
|
317
|
+
comments.push(twitterComment(reply, replyId));
|
|
318
|
+
if (comments.length >= limit) return comments;
|
|
319
|
+
}
|
|
320
|
+
cursor = object(payload.cursor) ? string(payload.cursor.bottom) : null;
|
|
321
|
+
if (!cursor || cursors.has(cursor) || comments.length === previousCount) break;
|
|
322
|
+
cursors.add(cursor);
|
|
323
|
+
} while (comments.length < limit);
|
|
324
|
+
return comments;
|
|
325
|
+
} catch {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function twitterComment(reply: Json, id: string): PostComment {
|
|
331
|
+
const author = object(reply.author) ? reply.author : {};
|
|
332
|
+
const verification = object(author.verification) ? author.verification : {};
|
|
333
|
+
const media = object(reply.media) && Array.isArray(reply.media.all) ? reply.media.all.filter(object) : [];
|
|
334
|
+
const items: MediaItem[] = media.flatMap((entry, index) => {
|
|
335
|
+
const url = string(entry.url);
|
|
336
|
+
const photo = entry.type === "photo";
|
|
337
|
+
if (!url || (!photo && entry.type !== "video" && entry.type !== "gif" && entry.type !== "animated_gif")) return [];
|
|
338
|
+
return [{
|
|
339
|
+
id,
|
|
340
|
+
platform: "twitter",
|
|
341
|
+
filename: filename(`twitter_${id}_${index + 1}.${photo ? "jpg" : "mp4"}`),
|
|
342
|
+
kind: photo ? "image" : "video",
|
|
343
|
+
mime: photo ? "image/jpeg" : "video/mp4",
|
|
344
|
+
url,
|
|
345
|
+
headers: { "user-agent": browserUserAgent() },
|
|
346
|
+
}];
|
|
347
|
+
});
|
|
348
|
+
return {
|
|
349
|
+
id,
|
|
350
|
+
url: `https://x.com/i/status/${id}`,
|
|
351
|
+
items,
|
|
352
|
+
metadata: {
|
|
353
|
+
text: string(reply.text) ?? undefined,
|
|
354
|
+
author: {
|
|
355
|
+
handle: string(author.screen_name) ?? undefined,
|
|
356
|
+
name: string(author.name) ?? undefined,
|
|
357
|
+
verified: bool(verification.verified),
|
|
358
|
+
},
|
|
359
|
+
createdAt: isoFromDateString(reply.created_at),
|
|
360
|
+
likeCount: count(reply.likes),
|
|
361
|
+
commentCount: count(reply.replies),
|
|
362
|
+
shareCount: count(reply.reposts),
|
|
363
|
+
viewCount: count(reply.views),
|
|
364
|
+
nsfw: bool(reply.possibly_sensitive),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
package/src/youtube.ts
CHANGED
|
@@ -105,7 +105,7 @@ function youtubeResult(
|
|
|
105
105
|
...(streams.hls ? { hls: true } : {}),
|
|
106
106
|
...(streams.audio ? { audio: { headers, url: streams.audio } } : {}),
|
|
107
107
|
};
|
|
108
|
-
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
108
|
+
return { archiveFilename: filename(`youtube_${id}.zip`), comments: [], id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
async function probeStream(net: Net, url: string, headers: HeadersInit): Promise<void> {
|