@postfetch/core 0.11.0 → 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.js +100 -1
- package/dist/internal.d.ts +2 -0
- package/dist/postfetch.d.ts +1 -1
- package/package.json +1 -1
- package/src/internal.ts +2 -0
- package/src/postfetch.ts +1 -1
- package/src/reddit.ts +90 -1
- package/src/twitter.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -1540,7 +1540,7 @@ async function resolveReddit(input) {
|
|
|
1540
1540
|
const token = await appToken(input.net);
|
|
1541
1541
|
const post = await fetchPost(input.net, token, id);
|
|
1542
1542
|
const items = await mediaItems3(input, post, id);
|
|
1543
|
-
return { archiveFilename: filename(`reddit_${id}.zip`), comments:
|
|
1543
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), comments: await redditComments(input, token, id), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
1544
1544
|
}
|
|
1545
1545
|
function redditMetadata(post) {
|
|
1546
1546
|
const author = string(post.author);
|
|
@@ -1790,6 +1790,102 @@ function extensionOf(mime) {
|
|
|
1790
1790
|
}
|
|
1791
1791
|
return "jpg";
|
|
1792
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
|
+
}
|
|
1793
1889
|
|
|
1794
1890
|
// src/soundcloud.ts
|
|
1795
1891
|
async function resolveSoundcloud(input) {
|
|
@@ -2212,6 +2308,9 @@ function twitterMetadata(tweet) {
|
|
|
2212
2308
|
}
|
|
2213
2309
|
const parent = object(tweet.parent) ? tweet.parent : null;
|
|
2214
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;
|
|
2215
2314
|
if (parent && parentId) {
|
|
2216
2315
|
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
2217
2316
|
}
|
package/dist/internal.d.ts
CHANGED
|
@@ -97,6 +97,8 @@ 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. */
|
package/dist/postfetch.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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
|
|
6
|
+
/** Maximum direct comments to fetch for X and Reddit. Other platforms ignore this option. Failures return an empty array. */
|
|
7
7
|
comments?: number;
|
|
8
8
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
9
9
|
preferredWidth?: number;
|
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/internal.ts
CHANGED
|
@@ -101,6 +101,8 @@ 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. */
|
package/src/postfetch.ts
CHANGED
|
@@ -13,7 +13,7 @@ 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
|
|
16
|
+
/** Maximum direct comments to fetch for X and Reddit. Other platforms ignore this option. Failures return an empty array. */
|
|
17
17
|
comments?: number;
|
|
18
18
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
19
19
|
preferredWidth?: number;
|
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`), comments:
|
|
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/twitter.ts
CHANGED
|
@@ -61,6 +61,8 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
61
61
|
}
|
|
62
62
|
const parent = object(tweet.parent) ? tweet.parent : null;
|
|
63
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;
|
|
64
66
|
if (parent && parentId) {
|
|
65
67
|
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
66
68
|
}
|