@postfetch/core 0.3.1 → 0.4.1
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 +229 -12
- package/dist/instagram.d.ts +5 -1
- package/dist/internal.d.ts +126 -4
- package/dist/pinterest.d.ts +4 -1
- package/dist/reddit.d.ts +4 -1
- package/dist/soundcloud.d.ts +4 -1
- package/dist/tiktok.d.ts +4 -1
- package/dist/twitter.d.ts +4 -1
- package/dist/youtube.d.ts +4 -1
- package/package.json +1 -1
- package/src/index.ts +17 -1
- package/src/instagram.ts +99 -2
- package/src/internal.ts +172 -3
- package/src/pinterest.ts +27 -1
- package/src/reddit.ts +26 -1
- package/src/soundcloud.ts +27 -1
- package/src/tiktok.ts +35 -1
- package/src/twitter.ts +27 -1
- package/src/youtube.ts +30 -1
package/dist/index.d.ts
CHANGED
|
@@ -22,4 +22,4 @@
|
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
23
|
export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
|
|
24
24
|
export { PostfetchError } from "./internal";
|
|
25
|
-
export type { MediaItem, MediaKind, Platform, PostfetchResult } from "./internal";
|
|
25
|
+
export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, YoutubeExtra, } from "./internal";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// src/internal.ts
|
|
2
2
|
class PostfetchError extends Error {
|
|
3
3
|
status;
|
|
4
|
-
|
|
4
|
+
reason;
|
|
5
|
+
constructor(status, message, reason) {
|
|
5
6
|
super(message);
|
|
6
7
|
this.status = status;
|
|
8
|
+
this.reason = reason;
|
|
7
9
|
this.name = "PostfetchError";
|
|
8
10
|
}
|
|
9
11
|
}
|
|
@@ -41,6 +43,29 @@ function string(value) {
|
|
|
41
43
|
function number(value) {
|
|
42
44
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
43
45
|
}
|
|
46
|
+
function bool(value) {
|
|
47
|
+
return typeof value === "boolean" ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
function count(value) {
|
|
50
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
54
|
+
return Number(value.trim());
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
function isoFromEpochSeconds(value) {
|
|
59
|
+
const seconds = count(value);
|
|
60
|
+
return seconds === undefined ? undefined : new Date(seconds * 1000).toISOString();
|
|
61
|
+
}
|
|
62
|
+
function isoFromDateString(value) {
|
|
63
|
+
if (typeof value !== "string") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const time = Date.parse(value);
|
|
67
|
+
return Number.isNaN(time) ? undefined : new Date(time).toISOString();
|
|
68
|
+
}
|
|
44
69
|
function asUrl(value) {
|
|
45
70
|
try {
|
|
46
71
|
return new URL(value);
|
|
@@ -227,13 +252,89 @@ async function resolveInstagram(input) {
|
|
|
227
252
|
const code = shortcode(input.url);
|
|
228
253
|
const media = await pageMedia(input.net, code, input.preferredWidth) ?? await mobileMedia(input.net, code, input.preferredWidth) ?? await embedMedia(input.net, code) ?? await graphqlMedia(input.net, code);
|
|
229
254
|
if (!media) {
|
|
230
|
-
throw
|
|
255
|
+
throw await instagramUnavailable(input.net, code);
|
|
231
256
|
}
|
|
232
257
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
233
258
|
if (items.length === 0) {
|
|
234
259
|
throw new Error("Instagram media url not found");
|
|
235
260
|
}
|
|
236
|
-
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, platform: "instagram" };
|
|
261
|
+
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
262
|
+
}
|
|
263
|
+
async function instagramUnavailable(net, code) {
|
|
264
|
+
const url = new URL("https://i.instagram.com/api/v1/oembed/");
|
|
265
|
+
url.searchParams.set("url", `https://www.instagram.com/p/${code}/`);
|
|
266
|
+
const response = await net(url.href, { headers: mobileHeaders() }, 1).catch(() => null);
|
|
267
|
+
const body = response ? await response.text().catch(() => "") : "";
|
|
268
|
+
const reason = instagramUnavailableReason(response?.status ?? 0, body);
|
|
269
|
+
return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason}`, reason);
|
|
270
|
+
}
|
|
271
|
+
function instagramUnavailableReason(status, body) {
|
|
272
|
+
if (status === 404) {
|
|
273
|
+
return "notFound";
|
|
274
|
+
}
|
|
275
|
+
if (status !== 0 && status < 400) {
|
|
276
|
+
return "unavailable";
|
|
277
|
+
}
|
|
278
|
+
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
279
|
+
return "ageRestricted";
|
|
280
|
+
}
|
|
281
|
+
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
282
|
+
return "private";
|
|
283
|
+
}
|
|
284
|
+
if (/log in|login_required|not logged/i.test(body)) {
|
|
285
|
+
return "loginRequired";
|
|
286
|
+
}
|
|
287
|
+
return "unavailable";
|
|
288
|
+
}
|
|
289
|
+
function reasonStatus(reason) {
|
|
290
|
+
switch (reason) {
|
|
291
|
+
case "ageRestricted":
|
|
292
|
+
return 451;
|
|
293
|
+
case "private":
|
|
294
|
+
return 403;
|
|
295
|
+
case "loginRequired":
|
|
296
|
+
return 401;
|
|
297
|
+
case "rateLimited":
|
|
298
|
+
return 429;
|
|
299
|
+
default:
|
|
300
|
+
return 404;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function instagramMetadata(media) {
|
|
304
|
+
const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
|
|
305
|
+
const countsHidden = bool(media.like_and_view_counts_disabled);
|
|
306
|
+
return {
|
|
307
|
+
text: instagramCaption(media),
|
|
308
|
+
author: user ? {
|
|
309
|
+
handle: string(user.username) ?? undefined,
|
|
310
|
+
name: string(user.full_name) ?? undefined,
|
|
311
|
+
verified: bool(user.is_verified)
|
|
312
|
+
} : undefined,
|
|
313
|
+
createdAt: isoFromEpochSeconds(media.taken_at ?? media.taken_at_timestamp),
|
|
314
|
+
likeCount: countsHidden ? undefined : count(media.like_count) ?? edgeCount(media.edge_media_preview_like) ?? edgeCount(media.edge_liked_by),
|
|
315
|
+
commentCount: count(media.comment_count) ?? edgeCount(media.edge_media_to_comment) ?? edgeCount(media.edge_media_to_parent_comment),
|
|
316
|
+
viewCount: countsHidden ? undefined : count(media.play_count) ?? count(media.view_count) ?? count(media.video_view_count),
|
|
317
|
+
extra: {
|
|
318
|
+
productType: string(media.product_type) ?? undefined,
|
|
319
|
+
countsHidden,
|
|
320
|
+
location: object(media.location) ? string(media.location.name) ?? undefined : undefined
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function instagramCaption(media) {
|
|
325
|
+
if (object(media.caption)) {
|
|
326
|
+
return string(media.caption.text) ?? undefined;
|
|
327
|
+
}
|
|
328
|
+
const direct = string(media.caption);
|
|
329
|
+
if (direct) {
|
|
330
|
+
return direct;
|
|
331
|
+
}
|
|
332
|
+
const edges = object(media.edge_media_to_caption) && Array.isArray(media.edge_media_to_caption.edges) ? media.edge_media_to_caption.edges : [];
|
|
333
|
+
const node = object(edges[0]) && object(edges[0].node) ? edges[0].node : null;
|
|
334
|
+
return node ? string(node.text) ?? undefined : undefined;
|
|
335
|
+
}
|
|
336
|
+
function edgeCount(value) {
|
|
337
|
+
return object(value) ? count(value.count) : undefined;
|
|
237
338
|
}
|
|
238
339
|
function shortcode(input) {
|
|
239
340
|
const path = asUrl(input).pathname.split("/").filter(Boolean);
|
|
@@ -692,7 +793,24 @@ async function resolvePinterest(input) {
|
|
|
692
793
|
if (items.length === 0) {
|
|
693
794
|
throw new Error("Pinterest media not found");
|
|
694
795
|
}
|
|
695
|
-
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, platform: "pinterest" };
|
|
796
|
+
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
797
|
+
}
|
|
798
|
+
function pinterestMetadata(pin) {
|
|
799
|
+
const pinner = object(pin.pinner) ? pin.pinner : null;
|
|
800
|
+
const reactions = object(pin.reaction_counts) ? Object.values(pin.reaction_counts).reduce((sum, value) => sum + (count(value) ?? 0), 0) : undefined;
|
|
801
|
+
return {
|
|
802
|
+
title: string(pin.title) ?? string(pin.grid_title) ?? undefined,
|
|
803
|
+
text: string(pin.description) ?? undefined,
|
|
804
|
+
author: pinner ? { handle: string(pinner.username) ?? undefined, name: string(pinner.full_name) ?? undefined } : undefined,
|
|
805
|
+
createdAt: isoFromDateString(pin.created_at),
|
|
806
|
+
likeCount: reactions,
|
|
807
|
+
commentCount: count(pin.comment_count),
|
|
808
|
+
extra: {
|
|
809
|
+
saveCount: count(pin.repin_count),
|
|
810
|
+
dominantColor: string(pin.dominant_color) ?? undefined,
|
|
811
|
+
outboundLink: string(pin.link) ?? undefined
|
|
812
|
+
}
|
|
813
|
+
};
|
|
696
814
|
}
|
|
697
815
|
async function pinId(net, input) {
|
|
698
816
|
const direct = parsePin(input);
|
|
@@ -893,7 +1011,25 @@ async function resolveReddit(input) {
|
|
|
893
1011
|
if (items.length === 0) {
|
|
894
1012
|
throw new Error("Reddit media not found");
|
|
895
1013
|
}
|
|
896
|
-
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, platform: "reddit" };
|
|
1014
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
1015
|
+
}
|
|
1016
|
+
function redditMetadata(post) {
|
|
1017
|
+
const author = string(post.author);
|
|
1018
|
+
return {
|
|
1019
|
+
title: string(post.title) ?? undefined,
|
|
1020
|
+
text: string(post.selftext) ?? undefined,
|
|
1021
|
+
author: author ? { handle: author } : undefined,
|
|
1022
|
+
createdAt: isoFromEpochSeconds(post.created_utc),
|
|
1023
|
+
likeCount: count(post.ups),
|
|
1024
|
+
commentCount: count(post.num_comments),
|
|
1025
|
+
nsfw: bool(post.over_18),
|
|
1026
|
+
extra: {
|
|
1027
|
+
subreddit: string(post.subreddit) ?? undefined,
|
|
1028
|
+
score: number(post.score) ?? undefined,
|
|
1029
|
+
upvoteRatio: number(post.upvote_ratio) ?? undefined,
|
|
1030
|
+
permalink: string(post.permalink) ?? undefined
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
897
1033
|
}
|
|
898
1034
|
async function postId(net, input) {
|
|
899
1035
|
const direct = parseId(input);
|
|
@@ -1133,7 +1269,26 @@ async function resolveSoundcloud(input) {
|
|
|
1133
1269
|
const track = await resolveTrack(input.net, url, clientId);
|
|
1134
1270
|
const id = trackId(track);
|
|
1135
1271
|
const item = await audioItem(input.net, track, id, clientId);
|
|
1136
|
-
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], platform: "soundcloud" };
|
|
1272
|
+
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
1273
|
+
}
|
|
1274
|
+
function soundcloudMetadata(track) {
|
|
1275
|
+
const user = object(track.user) ? track.user : null;
|
|
1276
|
+
const durationMs = count(track.duration);
|
|
1277
|
+
return {
|
|
1278
|
+
title: string(track.title) ?? undefined,
|
|
1279
|
+
text: string(track.description) ?? undefined,
|
|
1280
|
+
author: user ? { handle: string(user.username) ?? undefined, name: string(user.full_name) ?? undefined } : undefined,
|
|
1281
|
+
createdAt: isoFromDateString(track.created_at),
|
|
1282
|
+
likeCount: count(track.likes_count),
|
|
1283
|
+
commentCount: count(track.comment_count),
|
|
1284
|
+
shareCount: count(track.reposts_count),
|
|
1285
|
+
viewCount: count(track.playback_count),
|
|
1286
|
+
extra: {
|
|
1287
|
+
genre: string(track.genre) ?? undefined,
|
|
1288
|
+
license: string(track.license) ?? undefined,
|
|
1289
|
+
durationSeconds: durationMs === undefined ? undefined : Math.round(durationMs / 1000)
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1137
1292
|
}
|
|
1138
1293
|
async function canonicalUrl2(net, input) {
|
|
1139
1294
|
const host = asUrl(input).hostname;
|
|
@@ -1255,7 +1410,33 @@ async function resolveTiktok(input) {
|
|
|
1255
1410
|
if (items.length === 0) {
|
|
1256
1411
|
throw new Error("TikTok media not found");
|
|
1257
1412
|
}
|
|
1258
|
-
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, platform: "tiktok" };
|
|
1413
|
+
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
1414
|
+
}
|
|
1415
|
+
function tiktokMetadata(item) {
|
|
1416
|
+
const user = object(item.author) ? item.author : null;
|
|
1417
|
+
const stats = object(item.stats) ? item.stats : null;
|
|
1418
|
+
const music = object(item.music) ? item.music : null;
|
|
1419
|
+
const video = object(item.video) ? item.video : null;
|
|
1420
|
+
return {
|
|
1421
|
+
text: string(item.desc) ?? undefined,
|
|
1422
|
+
author: user ? {
|
|
1423
|
+
handle: string(user.uniqueId) ?? undefined,
|
|
1424
|
+
name: string(user.nickname) ?? undefined,
|
|
1425
|
+
verified: bool(user.verified)
|
|
1426
|
+
} : undefined,
|
|
1427
|
+
createdAt: isoFromEpochSeconds(item.createTime),
|
|
1428
|
+
likeCount: stats ? count(stats.diggCount) : undefined,
|
|
1429
|
+
commentCount: stats ? count(stats.commentCount) : undefined,
|
|
1430
|
+
shareCount: stats ? count(stats.shareCount) : undefined,
|
|
1431
|
+
viewCount: stats ? count(stats.playCount) : undefined,
|
|
1432
|
+
extra: {
|
|
1433
|
+
saveCount: stats ? count(stats.collectCount) : undefined,
|
|
1434
|
+
musicTitle: music ? string(music.title) ?? undefined : undefined,
|
|
1435
|
+
musicAuthor: music ? string(music.authorName) ?? undefined : undefined,
|
|
1436
|
+
region: string(item.locationCreated) ?? undefined,
|
|
1437
|
+
durationSeconds: video ? count(video.duration) : undefined
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1259
1440
|
}
|
|
1260
1441
|
async function fetchVideoPage(net, id, userAgent) {
|
|
1261
1442
|
const page = await net(`https://www.tiktok.com/@i/video/${id}`, {
|
|
@@ -1398,7 +1579,25 @@ async function resolveTwitter(input) {
|
|
|
1398
1579
|
if (items.length === 0) {
|
|
1399
1580
|
throw new Error("Twitter media not found");
|
|
1400
1581
|
}
|
|
1401
|
-
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, platform: "twitter" };
|
|
1582
|
+
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
1583
|
+
}
|
|
1584
|
+
function twitterMetadata(tweet) {
|
|
1585
|
+
const user = object(tweet.user) ? tweet.user : null;
|
|
1586
|
+
return {
|
|
1587
|
+
text: string(tweet.text) ?? string(tweet.full_text) ?? undefined,
|
|
1588
|
+
author: user ? {
|
|
1589
|
+
handle: string(user.screen_name) ?? undefined,
|
|
1590
|
+
name: string(user.name) ?? undefined,
|
|
1591
|
+
verified: bool(user.verified) ?? bool(user.is_blue_verified)
|
|
1592
|
+
} : undefined,
|
|
1593
|
+
createdAt: isoFromDateString(tweet.created_at),
|
|
1594
|
+
likeCount: count(tweet.favorite_count),
|
|
1595
|
+
commentCount: count(tweet.conversation_count),
|
|
1596
|
+
shareCount: count(tweet.retweet_count),
|
|
1597
|
+
viewCount: count(tweet.view_count),
|
|
1598
|
+
nsfw: bool(tweet.possibly_sensitive),
|
|
1599
|
+
extra: { lang: string(tweet.lang) ?? undefined }
|
|
1600
|
+
};
|
|
1402
1601
|
}
|
|
1403
1602
|
async function syndication(net, id) {
|
|
1404
1603
|
const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${syndicationToken(id)}`;
|
|
@@ -1483,7 +1682,25 @@ async function resolveYoutube(input) {
|
|
|
1483
1682
|
url: streams.video,
|
|
1484
1683
|
...streams.audio ? { audio: { headers, url: streams.audio } } : {}
|
|
1485
1684
|
};
|
|
1486
|
-
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], platform: "youtube" };
|
|
1685
|
+
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
1686
|
+
}
|
|
1687
|
+
function youtubeMetadata(payload) {
|
|
1688
|
+
const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
|
|
1689
|
+
const renderer = object(payload) && object(payload.microformat) && object(payload.microformat.playerMicroformatRenderer) ? payload.microformat.playerMicroformatRenderer : null;
|
|
1690
|
+
const author2 = details ? string(details.author) : null;
|
|
1691
|
+
const keywords = details && Array.isArray(details.keywords) ? details.keywords.filter((keyword) => typeof keyword === "string") : [];
|
|
1692
|
+
return {
|
|
1693
|
+
title: details ? string(details.title) ?? undefined : undefined,
|
|
1694
|
+
text: details ? string(details.shortDescription) ?? undefined : undefined,
|
|
1695
|
+
author: author2 ? { name: author2 } : undefined,
|
|
1696
|
+
createdAt: renderer ? isoFromDateString(renderer.publishDate) : undefined,
|
|
1697
|
+
viewCount: details ? count(details.viewCount) : undefined,
|
|
1698
|
+
extra: {
|
|
1699
|
+
channelId: details ? string(details.channelId) ?? undefined : undefined,
|
|
1700
|
+
durationSeconds: details ? count(details.lengthSeconds) : undefined,
|
|
1701
|
+
keywords: keywords.length > 0 ? keywords : undefined
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1487
1704
|
}
|
|
1488
1705
|
function selectStreams(payload, preferredWidth) {
|
|
1489
1706
|
const adaptive = adaptiveFormats(payload);
|
|
@@ -1896,10 +2113,10 @@ function writeCentral(view, target, offset, file, localOffset) {
|
|
|
1896
2113
|
target.set(file.name, offset + 46);
|
|
1897
2114
|
return offset + 46 + file.name.length;
|
|
1898
2115
|
}
|
|
1899
|
-
function writeEnd(view, offset,
|
|
2116
|
+
function writeEnd(view, offset, count2, centralSize, centralStart) {
|
|
1900
2117
|
view.setUint32(offset, 101010256, true);
|
|
1901
|
-
view.setUint16(offset + 8,
|
|
1902
|
-
view.setUint16(offset + 10,
|
|
2118
|
+
view.setUint16(offset + 8, count2, true);
|
|
2119
|
+
view.setUint16(offset + 10, count2, true);
|
|
1903
2120
|
view.setUint32(offset + 12, centralSize, true);
|
|
1904
2121
|
view.setUint32(offset + 16, centralStart, true);
|
|
1905
2122
|
}
|
package/dist/instagram.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type InstagramExtra, type PostMetadata, type PostfetchReason, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveInstagram(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function instagramUnavailableReason(status: number, body: string): PostfetchReason;
|
|
4
|
+
export declare function instagramMetadata(media: Json): PostMetadata & {
|
|
5
|
+
extra?: InstagramExtra;
|
|
6
|
+
};
|
package/dist/internal.d.ts
CHANGED
|
@@ -36,8 +36,110 @@ export type MediaItem = {
|
|
|
36
36
|
/** Direct CDN URL of the media. */
|
|
37
37
|
url: string;
|
|
38
38
|
};
|
|
39
|
-
/** The
|
|
40
|
-
export type
|
|
39
|
+
/** The author of a post, normalized across platforms. */
|
|
40
|
+
export type PostAuthor = {
|
|
41
|
+
/** Account handle (the `@username`), where the platform exposes one. */
|
|
42
|
+
handle?: string;
|
|
43
|
+
/** Display name, where it differs from the handle. */
|
|
44
|
+
name?: string;
|
|
45
|
+
/** Whether the account is verified, where the platform reports it. */
|
|
46
|
+
verified?: boolean;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Post metadata normalized across platforms. Every field is optional: an absent
|
|
50
|
+
* field means the platform did not expose it in the payload already fetched to
|
|
51
|
+
* resolve the media (never a fabricated default — a missing count is `undefined`,
|
|
52
|
+
* not `0`). All counts are non-negative. Platform-specific fields live in `extra`.
|
|
53
|
+
*/
|
|
54
|
+
export type PostMetadata = {
|
|
55
|
+
/** Short headline, where the platform has one distinct from the body (Reddit, YouTube, Pinterest, SoundCloud). */
|
|
56
|
+
title?: string;
|
|
57
|
+
/** Body text: caption, tweet, description or self-text. */
|
|
58
|
+
text?: string;
|
|
59
|
+
/** Who posted it. */
|
|
60
|
+
author?: PostAuthor;
|
|
61
|
+
/** Publication time as an ISO 8601 string. */
|
|
62
|
+
createdAt?: string;
|
|
63
|
+
/** Positive reactions (likes, upvotes, favorites, diggs). */
|
|
64
|
+
likeCount?: number;
|
|
65
|
+
/** Number of comments or replies. */
|
|
66
|
+
commentCount?: number;
|
|
67
|
+
/** Genuine reshares (TikTok shares, retweets, reposts) — not saves. */
|
|
68
|
+
shareCount?: number;
|
|
69
|
+
/** Views or plays. */
|
|
70
|
+
viewCount?: number;
|
|
71
|
+
/** Whether the post is marked not-safe-for-work, where the platform reports it. */
|
|
72
|
+
nsfw?: boolean;
|
|
73
|
+
};
|
|
74
|
+
/** Reddit-specific metadata. */
|
|
75
|
+
export type RedditExtra = {
|
|
76
|
+
/** Subreddit the post belongs to, without the `r/` prefix. */
|
|
77
|
+
subreddit?: string;
|
|
78
|
+
/** Net score (upvotes minus downvotes); can be negative. */
|
|
79
|
+
score?: number;
|
|
80
|
+
/** Fraction of votes that are upvotes, from 0 to 1. */
|
|
81
|
+
upvoteRatio?: number;
|
|
82
|
+
/** Path of the post on reddit.com, starting with `/r/`. */
|
|
83
|
+
permalink?: string;
|
|
84
|
+
};
|
|
85
|
+
/** TikTok-specific metadata. */
|
|
86
|
+
export type TiktokExtra = {
|
|
87
|
+
/** Times the video was added to favorites. */
|
|
88
|
+
saveCount?: number;
|
|
89
|
+
/** Title of the sound used. */
|
|
90
|
+
musicTitle?: string;
|
|
91
|
+
/** Author of the sound used. */
|
|
92
|
+
musicAuthor?: string;
|
|
93
|
+
/** Two-letter region the video was created in. */
|
|
94
|
+
region?: string;
|
|
95
|
+
/** Video duration in seconds. */
|
|
96
|
+
durationSeconds?: number;
|
|
97
|
+
};
|
|
98
|
+
/** X (Twitter)-specific metadata. */
|
|
99
|
+
export type TwitterExtra = {
|
|
100
|
+
/** BCP 47 language tag detected for the tweet. */
|
|
101
|
+
lang?: string;
|
|
102
|
+
};
|
|
103
|
+
/** Instagram-specific metadata. */
|
|
104
|
+
export type InstagramExtra = {
|
|
105
|
+
/** Product type, e.g. `clips` for a reel or `feed` for a feed post. */
|
|
106
|
+
productType?: string;
|
|
107
|
+
/** Whether the account hid its like and view counts. */
|
|
108
|
+
countsHidden?: boolean;
|
|
109
|
+
/** Tagged location name, when present. */
|
|
110
|
+
location?: string;
|
|
111
|
+
};
|
|
112
|
+
/** YouTube-specific metadata. */
|
|
113
|
+
export type YoutubeExtra = {
|
|
114
|
+
/** Channel id (`UC…`) of the uploader. */
|
|
115
|
+
channelId?: string;
|
|
116
|
+
/** Video duration in seconds. */
|
|
117
|
+
durationSeconds?: number;
|
|
118
|
+
/** Video keywords. */
|
|
119
|
+
keywords?: string[];
|
|
120
|
+
};
|
|
121
|
+
/** SoundCloud-specific metadata. */
|
|
122
|
+
export type SoundcloudExtra = {
|
|
123
|
+
/** Track genre, when set. */
|
|
124
|
+
genre?: string;
|
|
125
|
+
/** Track license, e.g. `all-rights-reserved`. */
|
|
126
|
+
license?: string;
|
|
127
|
+
/** Track duration in seconds. */
|
|
128
|
+
durationSeconds?: number;
|
|
129
|
+
};
|
|
130
|
+
/** Pinterest-specific metadata. */
|
|
131
|
+
export type PinterestExtra = {
|
|
132
|
+
/** Times the pin was saved (repinned). */
|
|
133
|
+
saveCount?: number;
|
|
134
|
+
/** Dominant color of the pin image, as a hex string. */
|
|
135
|
+
dominantColor?: string;
|
|
136
|
+
/** Outbound link the pin points to, when set. */
|
|
137
|
+
outboundLink?: string;
|
|
138
|
+
};
|
|
139
|
+
/** The result of resolving a post: its platform, id, media items and metadata. */
|
|
140
|
+
export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
|
|
141
|
+
/** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
|
|
142
|
+
export type PlatformResult<P extends Platform, Extra> = {
|
|
41
143
|
/** Suggested filename when zipping every item. */
|
|
42
144
|
archiveFilename: string;
|
|
43
145
|
/** Platform post/media id. */
|
|
@@ -45,7 +147,12 @@ export type PostfetchResult = {
|
|
|
45
147
|
/** The post's media, in order. */
|
|
46
148
|
items: MediaItem[];
|
|
47
149
|
/** The platform the post came from. */
|
|
48
|
-
platform:
|
|
150
|
+
platform: P;
|
|
151
|
+
/** Normalized post metadata, when any was available. */
|
|
152
|
+
metadata?: PostMetadata & {
|
|
153
|
+
/** Platform-specific metadata not covered by the normalized fields. */
|
|
154
|
+
extra?: Extra;
|
|
155
|
+
};
|
|
49
156
|
};
|
|
50
157
|
export type Net = (url: string, init?: RequestInit, attempts?: number) => Promise<Response>;
|
|
51
158
|
export type ResolveContext = {
|
|
@@ -53,24 +160,39 @@ export type ResolveContext = {
|
|
|
53
160
|
preferredWidth: number;
|
|
54
161
|
url: string;
|
|
55
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* Why a post could not be resolved, when the platform makes the cause knowable.
|
|
165
|
+
* Lets callers tell a user-facing limitation (age-gated, private, removed) apart
|
|
166
|
+
* from a transient/technical failure.
|
|
167
|
+
*/
|
|
168
|
+
export type PostfetchReason = "ageRestricted" | "private" | "loginRequired" | "deleted" | "notFound" | "rateLimited" | "unavailable";
|
|
56
169
|
/** An error carrying an HTTP status, so adapters can map failures onto responses. */
|
|
57
170
|
export declare class PostfetchError extends Error {
|
|
58
171
|
/** HTTP status that best describes the failure. */
|
|
59
172
|
readonly status: number;
|
|
173
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
174
|
+
readonly reason?: PostfetchReason | undefined;
|
|
60
175
|
/**
|
|
61
176
|
* Create an error tagged with an HTTP status.
|
|
62
177
|
*
|
|
63
178
|
* @param status HTTP status that best describes the failure.
|
|
64
179
|
* @param message Human-readable message.
|
|
180
|
+
* @param reason Machine-readable cause, when the platform makes it knowable.
|
|
65
181
|
*/
|
|
66
182
|
constructor(
|
|
67
183
|
/** HTTP status that best describes the failure. */
|
|
68
|
-
status: number, message: string
|
|
184
|
+
status: number, message: string,
|
|
185
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
186
|
+
reason?: PostfetchReason | undefined);
|
|
69
187
|
}
|
|
70
188
|
export type Json = Record<string, unknown>;
|
|
71
189
|
export declare function createNet(baseFetch?: typeof fetch): Net;
|
|
72
190
|
export declare function object(value: unknown): value is Json;
|
|
73
191
|
export declare function string(value: unknown): string | null;
|
|
74
192
|
export declare function number(value: unknown): number | null;
|
|
193
|
+
export declare function bool(value: unknown): boolean | undefined;
|
|
194
|
+
export declare function count(value: unknown): number | undefined;
|
|
195
|
+
export declare function isoFromEpochSeconds(value: unknown): string | undefined;
|
|
196
|
+
export declare function isoFromDateString(value: unknown): string | undefined;
|
|
75
197
|
export declare function asUrl(value: string): URL;
|
|
76
198
|
export declare function filename(value: string): string;
|
package/dist/pinterest.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PinterestExtra, type PostMetadata, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolvePinterest(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function pinterestMetadata(pin: Json): PostMetadata & {
|
|
4
|
+
extra?: PinterestExtra;
|
|
5
|
+
};
|
package/dist/reddit.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PostMetadata, type RedditExtra, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveReddit(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function redditMetadata(post: Json): PostMetadata & {
|
|
4
|
+
extra?: RedditExtra;
|
|
5
|
+
};
|
package/dist/soundcloud.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PostMetadata, type SoundcloudExtra, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveSoundcloud(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function soundcloudMetadata(track: Json): PostMetadata & {
|
|
4
|
+
extra?: SoundcloudExtra;
|
|
5
|
+
};
|
package/dist/tiktok.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PostMetadata, type TiktokExtra, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveTiktok(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function tiktokMetadata(item: Json): PostMetadata & {
|
|
4
|
+
extra?: TiktokExtra;
|
|
5
|
+
};
|
|
3
6
|
export declare function isShortlinkHost(hostname: string): boolean;
|
package/dist/twitter.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PostMetadata, type TwitterExtra, type ResolveContext, type Json, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveTwitter(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function twitterMetadata(tweet: Json): PostMetadata & {
|
|
4
|
+
extra?: TwitterExtra;
|
|
5
|
+
};
|
|
3
6
|
export declare function tweetId(input: string): string | null;
|
package/dist/youtube.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
import { type ResolveContext, type PostfetchResult } from "./internal";
|
|
1
|
+
import { type PostMetadata, type YoutubeExtra, type ResolveContext, type PostfetchResult } from "./internal";
|
|
2
2
|
export declare function resolveYoutube(input: ResolveContext): Promise<PostfetchResult>;
|
|
3
|
+
export declare function youtubeMetadata(payload: unknown): PostMetadata & {
|
|
4
|
+
extra?: YoutubeExtra;
|
|
5
|
+
};
|
|
3
6
|
export declare function youtubeVideoId(input: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -22,4 +22,20 @@
|
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
23
|
export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
|
|
24
24
|
export { PostfetchError } from "./internal";
|
|
25
|
-
export type {
|
|
25
|
+
export type {
|
|
26
|
+
InstagramExtra,
|
|
27
|
+
MediaItem,
|
|
28
|
+
MediaKind,
|
|
29
|
+
PinterestExtra,
|
|
30
|
+
Platform,
|
|
31
|
+
PlatformResult,
|
|
32
|
+
PostAuthor,
|
|
33
|
+
PostfetchReason,
|
|
34
|
+
PostfetchResult,
|
|
35
|
+
PostMetadata,
|
|
36
|
+
RedditExtra,
|
|
37
|
+
SoundcloudExtra,
|
|
38
|
+
TiktokExtra,
|
|
39
|
+
TwitterExtra,
|
|
40
|
+
YoutubeExtra,
|
|
41
|
+
} from "./internal";
|
package/src/instagram.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
bool,
|
|
4
|
+
count,
|
|
3
5
|
filename,
|
|
6
|
+
isoFromEpochSeconds,
|
|
4
7
|
number,
|
|
5
8
|
object,
|
|
9
|
+
PostfetchError,
|
|
6
10
|
string,
|
|
11
|
+
type InstagramExtra,
|
|
12
|
+
type PostMetadata,
|
|
13
|
+
type PostfetchReason,
|
|
7
14
|
type ResolveContext,
|
|
8
15
|
type Net,
|
|
9
16
|
type Json,
|
|
@@ -57,13 +64,103 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
57
64
|
(await embedMedia(input.net, code)) ??
|
|
58
65
|
(await graphqlMedia(input.net, code));
|
|
59
66
|
if (!media) {
|
|
60
|
-
throw
|
|
67
|
+
throw await instagramUnavailable(input.net, code);
|
|
61
68
|
}
|
|
62
69
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
63
70
|
if (items.length === 0) {
|
|
64
71
|
throw new Error("Instagram media url not found");
|
|
65
72
|
}
|
|
66
|
-
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, platform: "instagram" };
|
|
73
|
+
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function instagramUnavailable(net: Net, code: string): Promise<PostfetchError> {
|
|
77
|
+
const url = new URL("https://i.instagram.com/api/v1/oembed/");
|
|
78
|
+
url.searchParams.set("url", `https://www.instagram.com/p/${code}/`);
|
|
79
|
+
const response = await net(url.href, { headers: mobileHeaders() }, 1).catch(() => null);
|
|
80
|
+
const body = response ? await response.text().catch(() => "") : "";
|
|
81
|
+
const reason = instagramUnavailableReason(response?.status ?? 0, body);
|
|
82
|
+
return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason}`, reason);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Classifies why the post is gone from the oembed endpoint, which returns a
|
|
86
|
+
// telling status and body (404 when removed; 400 with a MIN_AGE_ACCOUNT block
|
|
87
|
+
// for 18+ accounts) while the media endpoints just come back empty.
|
|
88
|
+
export function instagramUnavailableReason(status: number, body: string): PostfetchReason {
|
|
89
|
+
if (status === 404) {
|
|
90
|
+
return "notFound";
|
|
91
|
+
}
|
|
92
|
+
if (status !== 0 && status < 400) {
|
|
93
|
+
return "unavailable";
|
|
94
|
+
}
|
|
95
|
+
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
96
|
+
return "ageRestricted";
|
|
97
|
+
}
|
|
98
|
+
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
99
|
+
return "private";
|
|
100
|
+
}
|
|
101
|
+
if (/log in|login_required|not logged/i.test(body)) {
|
|
102
|
+
return "loginRequired";
|
|
103
|
+
}
|
|
104
|
+
return "unavailable";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reasonStatus(reason: PostfetchReason): number {
|
|
108
|
+
switch (reason) {
|
|
109
|
+
case "ageRestricted":
|
|
110
|
+
return 451;
|
|
111
|
+
case "private":
|
|
112
|
+
return 403;
|
|
113
|
+
case "loginRequired":
|
|
114
|
+
return 401;
|
|
115
|
+
case "rateLimited":
|
|
116
|
+
return 429;
|
|
117
|
+
default:
|
|
118
|
+
return 404;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function instagramMetadata(media: Json): PostMetadata & { extra?: InstagramExtra } {
|
|
123
|
+
const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
|
|
124
|
+
const countsHidden = bool(media.like_and_view_counts_disabled);
|
|
125
|
+
return {
|
|
126
|
+
text: instagramCaption(media),
|
|
127
|
+
author: user
|
|
128
|
+
? {
|
|
129
|
+
handle: string(user.username) ?? undefined,
|
|
130
|
+
name: string(user.full_name) ?? undefined,
|
|
131
|
+
verified: bool(user.is_verified),
|
|
132
|
+
}
|
|
133
|
+
: undefined,
|
|
134
|
+
createdAt: isoFromEpochSeconds(media.taken_at ?? media.taken_at_timestamp),
|
|
135
|
+
likeCount: countsHidden ? undefined : count(media.like_count) ?? edgeCount(media.edge_media_preview_like) ?? edgeCount(media.edge_liked_by),
|
|
136
|
+
commentCount: count(media.comment_count) ?? edgeCount(media.edge_media_to_comment) ?? edgeCount(media.edge_media_to_parent_comment),
|
|
137
|
+
viewCount: countsHidden ? undefined : count(media.play_count) ?? count(media.view_count) ?? count(media.video_view_count),
|
|
138
|
+
extra: {
|
|
139
|
+
productType: string(media.product_type) ?? undefined,
|
|
140
|
+
countsHidden,
|
|
141
|
+
location: object(media.location) ? string(media.location.name) ?? undefined : undefined,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Caption arrives as an object ({ text }), a bare string, or the GraphQL
|
|
147
|
+
// edge_media_to_caption edge list, depending on which endpoint resolved the post.
|
|
148
|
+
function instagramCaption(media: Json): string | undefined {
|
|
149
|
+
if (object(media.caption)) {
|
|
150
|
+
return string(media.caption.text) ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
const direct = string(media.caption);
|
|
153
|
+
if (direct) {
|
|
154
|
+
return direct;
|
|
155
|
+
}
|
|
156
|
+
const edges =
|
|
157
|
+
object(media.edge_media_to_caption) && Array.isArray(media.edge_media_to_caption.edges) ? media.edge_media_to_caption.edges : [];
|
|
158
|
+
const node = object(edges[0]) && object(edges[0].node) ? edges[0].node : null;
|
|
159
|
+
return node ? string(node.text) ?? undefined : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function edgeCount(value: unknown): number | undefined {
|
|
163
|
+
return object(value) ? count(value.count) : undefined;
|
|
67
164
|
}
|
|
68
165
|
|
|
69
166
|
function shortcode(input: string): string {
|
package/src/internal.ts
CHANGED
|
@@ -36,8 +36,128 @@ export type MediaItem = {
|
|
|
36
36
|
url: string;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
/** The
|
|
40
|
-
export type
|
|
39
|
+
/** The author of a post, normalized across platforms. */
|
|
40
|
+
export type PostAuthor = {
|
|
41
|
+
/** Account handle (the `@username`), where the platform exposes one. */
|
|
42
|
+
handle?: string;
|
|
43
|
+
/** Display name, where it differs from the handle. */
|
|
44
|
+
name?: string;
|
|
45
|
+
/** Whether the account is verified, where the platform reports it. */
|
|
46
|
+
verified?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Post metadata normalized across platforms. Every field is optional: an absent
|
|
51
|
+
* field means the platform did not expose it in the payload already fetched to
|
|
52
|
+
* resolve the media (never a fabricated default — a missing count is `undefined`,
|
|
53
|
+
* not `0`). All counts are non-negative. Platform-specific fields live in `extra`.
|
|
54
|
+
*/
|
|
55
|
+
export type PostMetadata = {
|
|
56
|
+
/** Short headline, where the platform has one distinct from the body (Reddit, YouTube, Pinterest, SoundCloud). */
|
|
57
|
+
title?: string;
|
|
58
|
+
/** Body text: caption, tweet, description or self-text. */
|
|
59
|
+
text?: string;
|
|
60
|
+
/** Who posted it. */
|
|
61
|
+
author?: PostAuthor;
|
|
62
|
+
/** Publication time as an ISO 8601 string. */
|
|
63
|
+
createdAt?: string;
|
|
64
|
+
/** Positive reactions (likes, upvotes, favorites, diggs). */
|
|
65
|
+
likeCount?: number;
|
|
66
|
+
/** Number of comments or replies. */
|
|
67
|
+
commentCount?: number;
|
|
68
|
+
/** Genuine reshares (TikTok shares, retweets, reposts) — not saves. */
|
|
69
|
+
shareCount?: number;
|
|
70
|
+
/** Views or plays. */
|
|
71
|
+
viewCount?: number;
|
|
72
|
+
/** Whether the post is marked not-safe-for-work, where the platform reports it. */
|
|
73
|
+
nsfw?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Reddit-specific metadata. */
|
|
77
|
+
export type RedditExtra = {
|
|
78
|
+
/** Subreddit the post belongs to, without the `r/` prefix. */
|
|
79
|
+
subreddit?: string;
|
|
80
|
+
/** Net score (upvotes minus downvotes); can be negative. */
|
|
81
|
+
score?: number;
|
|
82
|
+
/** Fraction of votes that are upvotes, from 0 to 1. */
|
|
83
|
+
upvoteRatio?: number;
|
|
84
|
+
/** Path of the post on reddit.com, starting with `/r/`. */
|
|
85
|
+
permalink?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** TikTok-specific metadata. */
|
|
89
|
+
export type TiktokExtra = {
|
|
90
|
+
/** Times the video was added to favorites. */
|
|
91
|
+
saveCount?: number;
|
|
92
|
+
/** Title of the sound used. */
|
|
93
|
+
musicTitle?: string;
|
|
94
|
+
/** Author of the sound used. */
|
|
95
|
+
musicAuthor?: string;
|
|
96
|
+
/** Two-letter region the video was created in. */
|
|
97
|
+
region?: string;
|
|
98
|
+
/** Video duration in seconds. */
|
|
99
|
+
durationSeconds?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** X (Twitter)-specific metadata. */
|
|
103
|
+
export type TwitterExtra = {
|
|
104
|
+
/** BCP 47 language tag detected for the tweet. */
|
|
105
|
+
lang?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Instagram-specific metadata. */
|
|
109
|
+
export type InstagramExtra = {
|
|
110
|
+
/** Product type, e.g. `clips` for a reel or `feed` for a feed post. */
|
|
111
|
+
productType?: string;
|
|
112
|
+
/** Whether the account hid its like and view counts. */
|
|
113
|
+
countsHidden?: boolean;
|
|
114
|
+
/** Tagged location name, when present. */
|
|
115
|
+
location?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** YouTube-specific metadata. */
|
|
119
|
+
export type YoutubeExtra = {
|
|
120
|
+
/** Channel id (`UC…`) of the uploader. */
|
|
121
|
+
channelId?: string;
|
|
122
|
+
/** Video duration in seconds. */
|
|
123
|
+
durationSeconds?: number;
|
|
124
|
+
/** Video keywords. */
|
|
125
|
+
keywords?: string[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** SoundCloud-specific metadata. */
|
|
129
|
+
export type SoundcloudExtra = {
|
|
130
|
+
/** Track genre, when set. */
|
|
131
|
+
genre?: string;
|
|
132
|
+
/** Track license, e.g. `all-rights-reserved`. */
|
|
133
|
+
license?: string;
|
|
134
|
+
/** Track duration in seconds. */
|
|
135
|
+
durationSeconds?: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Pinterest-specific metadata. */
|
|
139
|
+
export type PinterestExtra = {
|
|
140
|
+
/** Times the pin was saved (repinned). */
|
|
141
|
+
saveCount?: number;
|
|
142
|
+
/** Dominant color of the pin image, as a hex string. */
|
|
143
|
+
dominantColor?: string;
|
|
144
|
+
/** Outbound link the pin points to, when set. */
|
|
145
|
+
outboundLink?: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The result of resolving a post: its platform, id, media items and metadata. */
|
|
149
|
+
export type PostfetchResult =
|
|
150
|
+
| PlatformResult<"facebook", never>
|
|
151
|
+
| PlatformResult<"instagram", InstagramExtra>
|
|
152
|
+
| PlatformResult<"pinterest", PinterestExtra>
|
|
153
|
+
| PlatformResult<"reddit", RedditExtra>
|
|
154
|
+
| PlatformResult<"soundcloud", SoundcloudExtra>
|
|
155
|
+
| PlatformResult<"tiktok", TiktokExtra>
|
|
156
|
+
| PlatformResult<"twitter", TwitterExtra>
|
|
157
|
+
| PlatformResult<"youtube", YoutubeExtra>;
|
|
158
|
+
|
|
159
|
+
/** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
|
|
160
|
+
export type PlatformResult<P extends Platform, Extra> = {
|
|
41
161
|
/** Suggested filename when zipping every item. */
|
|
42
162
|
archiveFilename: string;
|
|
43
163
|
/** Platform post/media id. */
|
|
@@ -45,7 +165,12 @@ export type PostfetchResult = {
|
|
|
45
165
|
/** The post's media, in order. */
|
|
46
166
|
items: MediaItem[];
|
|
47
167
|
/** The platform the post came from. */
|
|
48
|
-
platform:
|
|
168
|
+
platform: P;
|
|
169
|
+
/** Normalized post metadata, when any was available. */
|
|
170
|
+
metadata?: PostMetadata & {
|
|
171
|
+
/** Platform-specific metadata not covered by the normalized fields. */
|
|
172
|
+
extra?: Extra;
|
|
173
|
+
};
|
|
49
174
|
};
|
|
50
175
|
|
|
51
176
|
export type Net = (url: string, init?: RequestInit, attempts?: number) => Promise<Response>;
|
|
@@ -56,6 +181,20 @@ export type ResolveContext = {
|
|
|
56
181
|
url: string;
|
|
57
182
|
};
|
|
58
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Why a post could not be resolved, when the platform makes the cause knowable.
|
|
186
|
+
* Lets callers tell a user-facing limitation (age-gated, private, removed) apart
|
|
187
|
+
* from a transient/technical failure.
|
|
188
|
+
*/
|
|
189
|
+
export type PostfetchReason =
|
|
190
|
+
| "ageRestricted"
|
|
191
|
+
| "private"
|
|
192
|
+
| "loginRequired"
|
|
193
|
+
| "deleted"
|
|
194
|
+
| "notFound"
|
|
195
|
+
| "rateLimited"
|
|
196
|
+
| "unavailable";
|
|
197
|
+
|
|
59
198
|
/** An error carrying an HTTP status, so adapters can map failures onto responses. */
|
|
60
199
|
export class PostfetchError extends Error {
|
|
61
200
|
/**
|
|
@@ -63,11 +202,14 @@ export class PostfetchError extends Error {
|
|
|
63
202
|
*
|
|
64
203
|
* @param status HTTP status that best describes the failure.
|
|
65
204
|
* @param message Human-readable message.
|
|
205
|
+
* @param reason Machine-readable cause, when the platform makes it knowable.
|
|
66
206
|
*/
|
|
67
207
|
constructor(
|
|
68
208
|
/** HTTP status that best describes the failure. */
|
|
69
209
|
readonly status: number,
|
|
70
210
|
message: string,
|
|
211
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
212
|
+
readonly reason?: PostfetchReason,
|
|
71
213
|
) {
|
|
72
214
|
super(message);
|
|
73
215
|
this.name = "PostfetchError";
|
|
@@ -114,6 +256,33 @@ export function number(value: unknown): number | null {
|
|
|
114
256
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
115
257
|
}
|
|
116
258
|
|
|
259
|
+
export function bool(value: unknown): boolean | undefined {
|
|
260
|
+
return typeof value === "boolean" ? value : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function count(value: unknown): number | undefined {
|
|
264
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
268
|
+
return Number(value.trim());
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function isoFromEpochSeconds(value: unknown): string | undefined {
|
|
274
|
+
const seconds = count(value);
|
|
275
|
+
return seconds === undefined ? undefined : new Date(seconds * 1000).toISOString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function isoFromDateString(value: unknown): string | undefined {
|
|
279
|
+
if (typeof value !== "string") {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
const time = Date.parse(value);
|
|
283
|
+
return Number.isNaN(time) ? undefined : new Date(time).toISOString();
|
|
284
|
+
}
|
|
285
|
+
|
|
117
286
|
export function asUrl(value: string): URL {
|
|
118
287
|
try {
|
|
119
288
|
return new URL(value);
|
package/src/pinterest.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
count,
|
|
3
4
|
filename,
|
|
5
|
+
isoFromDateString,
|
|
4
6
|
number,
|
|
5
7
|
object,
|
|
6
8
|
string,
|
|
9
|
+
type PinterestExtra,
|
|
10
|
+
type PostMetadata,
|
|
7
11
|
type ResolveContext,
|
|
8
12
|
type Json,
|
|
9
13
|
type Net,
|
|
@@ -20,7 +24,29 @@ export async function resolvePinterest(input: ResolveContext): Promise<Postfetch
|
|
|
20
24
|
if (items.length === 0) {
|
|
21
25
|
throw new Error("Pinterest media not found");
|
|
22
26
|
}
|
|
23
|
-
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, platform: "pinterest" };
|
|
27
|
+
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, metadata: pinterestMetadata(pin), platform: "pinterest" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function pinterestMetadata(pin: Json): PostMetadata & { extra?: PinterestExtra } {
|
|
31
|
+
const pinner = object(pin.pinner) ? pin.pinner : null;
|
|
32
|
+
const reactions = object(pin.reaction_counts)
|
|
33
|
+
? Object.values(pin.reaction_counts).reduce<number>((sum, value) => sum + (count(value) ?? 0), 0)
|
|
34
|
+
: undefined;
|
|
35
|
+
return {
|
|
36
|
+
title: (string(pin.title) ?? string(pin.grid_title)) ?? undefined,
|
|
37
|
+
text: string(pin.description) ?? undefined,
|
|
38
|
+
author: pinner
|
|
39
|
+
? { handle: string(pinner.username) ?? undefined, name: string(pinner.full_name) ?? undefined }
|
|
40
|
+
: undefined,
|
|
41
|
+
createdAt: isoFromDateString(pin.created_at),
|
|
42
|
+
likeCount: reactions,
|
|
43
|
+
commentCount: count(pin.comment_count),
|
|
44
|
+
extra: {
|
|
45
|
+
saveCount: count(pin.repin_count),
|
|
46
|
+
dominantColor: string(pin.dominant_color) ?? undefined,
|
|
47
|
+
outboundLink: string(pin.link) ?? undefined,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
24
50
|
}
|
|
25
51
|
|
|
26
52
|
// pin.it short links carry no pin id, so they are followed once to the canonical
|
package/src/reddit.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
bool,
|
|
4
|
+
count,
|
|
3
5
|
filename,
|
|
6
|
+
isoFromEpochSeconds,
|
|
7
|
+
number,
|
|
4
8
|
object,
|
|
5
9
|
string,
|
|
10
|
+
type PostMetadata,
|
|
11
|
+
type RedditExtra,
|
|
6
12
|
type ResolveContext,
|
|
7
13
|
type Json,
|
|
8
14
|
type Net,
|
|
@@ -25,7 +31,26 @@ export async function resolveReddit(input: ResolveContext): Promise<PostfetchRes
|
|
|
25
31
|
if (items.length === 0) {
|
|
26
32
|
throw new Error("Reddit media not found");
|
|
27
33
|
}
|
|
28
|
-
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, platform: "reddit" };
|
|
34
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, metadata: redditMetadata(post), platform: "reddit" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function redditMetadata(post: Json): PostMetadata & { extra?: RedditExtra } {
|
|
38
|
+
const author = string(post.author);
|
|
39
|
+
return {
|
|
40
|
+
title: string(post.title) ?? undefined,
|
|
41
|
+
text: string(post.selftext) ?? undefined,
|
|
42
|
+
author: author ? { handle: author } : undefined,
|
|
43
|
+
createdAt: isoFromEpochSeconds(post.created_utc),
|
|
44
|
+
likeCount: count(post.ups),
|
|
45
|
+
commentCount: count(post.num_comments),
|
|
46
|
+
nsfw: bool(post.over_18),
|
|
47
|
+
extra: {
|
|
48
|
+
subreddit: string(post.subreddit) ?? undefined,
|
|
49
|
+
score: number(post.score) ?? undefined,
|
|
50
|
+
upvoteRatio: number(post.upvote_ratio) ?? undefined,
|
|
51
|
+
permalink: string(post.permalink) ?? undefined,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
29
54
|
}
|
|
30
55
|
|
|
31
56
|
// Share and short links (redd.it/<id>, /r/<sub>/s/<share>) carry no post id, so
|
package/src/soundcloud.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
count,
|
|
3
4
|
filename,
|
|
5
|
+
isoFromDateString,
|
|
4
6
|
number,
|
|
5
7
|
object,
|
|
6
8
|
string,
|
|
9
|
+
type PostMetadata,
|
|
10
|
+
type SoundcloudExtra,
|
|
7
11
|
type ResolveContext,
|
|
8
12
|
type Json,
|
|
9
13
|
type Net,
|
|
@@ -18,7 +22,29 @@ export async function resolveSoundcloud(input: ResolveContext): Promise<Postfetc
|
|
|
18
22
|
const track = await resolveTrack(input.net, url, clientId);
|
|
19
23
|
const id = trackId(track);
|
|
20
24
|
const item = await audioItem(input.net, track, id, clientId);
|
|
21
|
-
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], platform: "soundcloud" };
|
|
25
|
+
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], metadata: soundcloudMetadata(track), platform: "soundcloud" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function soundcloudMetadata(track: Json): PostMetadata & { extra?: SoundcloudExtra } {
|
|
29
|
+
const user = object(track.user) ? track.user : null;
|
|
30
|
+
const durationMs = count(track.duration);
|
|
31
|
+
return {
|
|
32
|
+
title: string(track.title) ?? undefined,
|
|
33
|
+
text: string(track.description) ?? undefined,
|
|
34
|
+
author: user
|
|
35
|
+
? { handle: string(user.username) ?? undefined, name: string(user.full_name) ?? undefined }
|
|
36
|
+
: undefined,
|
|
37
|
+
createdAt: isoFromDateString(track.created_at),
|
|
38
|
+
likeCount: count(track.likes_count),
|
|
39
|
+
commentCount: count(track.comment_count),
|
|
40
|
+
shareCount: count(track.reposts_count),
|
|
41
|
+
viewCount: count(track.playback_count),
|
|
42
|
+
extra: {
|
|
43
|
+
genre: string(track.genre) ?? undefined,
|
|
44
|
+
license: string(track.license) ?? undefined,
|
|
45
|
+
durationSeconds: durationMs === undefined ? undefined : Math.round(durationMs / 1000),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
22
48
|
}
|
|
23
49
|
|
|
24
50
|
async function canonicalUrl(net: Net, input: string): Promise<string> {
|
package/src/tiktok.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
bool,
|
|
4
|
+
count,
|
|
3
5
|
filename,
|
|
6
|
+
isoFromEpochSeconds,
|
|
4
7
|
object,
|
|
5
8
|
string,
|
|
9
|
+
type PostMetadata,
|
|
10
|
+
type TiktokExtra,
|
|
6
11
|
type ResolveContext,
|
|
7
12
|
type Json,
|
|
8
13
|
type Net,
|
|
@@ -38,7 +43,36 @@ export async function resolveTiktok(input: ResolveContext): Promise<PostfetchRes
|
|
|
38
43
|
if (items.length === 0) {
|
|
39
44
|
throw new Error("TikTok media not found");
|
|
40
45
|
}
|
|
41
|
-
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, platform: "tiktok" };
|
|
46
|
+
return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function tiktokMetadata(item: Json): PostMetadata & { extra?: TiktokExtra } {
|
|
50
|
+
const user = object(item.author) ? item.author : null;
|
|
51
|
+
const stats = object(item.stats) ? item.stats : null;
|
|
52
|
+
const music = object(item.music) ? item.music : null;
|
|
53
|
+
const video = object(item.video) ? item.video : null;
|
|
54
|
+
return {
|
|
55
|
+
text: string(item.desc) ?? undefined,
|
|
56
|
+
author: user
|
|
57
|
+
? {
|
|
58
|
+
handle: string(user.uniqueId) ?? undefined,
|
|
59
|
+
name: string(user.nickname) ?? undefined,
|
|
60
|
+
verified: bool(user.verified),
|
|
61
|
+
}
|
|
62
|
+
: undefined,
|
|
63
|
+
createdAt: isoFromEpochSeconds(item.createTime),
|
|
64
|
+
likeCount: stats ? count(stats.diggCount) : undefined,
|
|
65
|
+
commentCount: stats ? count(stats.commentCount) : undefined,
|
|
66
|
+
shareCount: stats ? count(stats.shareCount) : undefined,
|
|
67
|
+
viewCount: stats ? count(stats.playCount) : undefined,
|
|
68
|
+
extra: {
|
|
69
|
+
saveCount: stats ? count(stats.collectCount) : undefined,
|
|
70
|
+
musicTitle: music ? string(music.title) ?? undefined : undefined,
|
|
71
|
+
musicAuthor: music ? string(music.authorName) ?? undefined : undefined,
|
|
72
|
+
region: string(item.locationCreated) ?? undefined,
|
|
73
|
+
durationSeconds: video ? count(video.duration) : undefined,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
42
76
|
}
|
|
43
77
|
|
|
44
78
|
async function fetchVideoPage(
|
package/src/twitter.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
bool,
|
|
4
|
+
count,
|
|
3
5
|
filename,
|
|
6
|
+
isoFromDateString,
|
|
4
7
|
number,
|
|
5
8
|
object,
|
|
6
9
|
string,
|
|
10
|
+
type PostMetadata,
|
|
11
|
+
type TwitterExtra,
|
|
7
12
|
type ResolveContext,
|
|
8
13
|
type Json,
|
|
9
14
|
type Net,
|
|
@@ -26,7 +31,28 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
26
31
|
if (items.length === 0) {
|
|
27
32
|
throw new Error("Twitter media not found");
|
|
28
33
|
}
|
|
29
|
-
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, platform: "twitter" };
|
|
34
|
+
return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterExtra } {
|
|
38
|
+
const user = object(tweet.user) ? tweet.user : null;
|
|
39
|
+
return {
|
|
40
|
+
text: (string(tweet.text) ?? string(tweet.full_text)) ?? undefined,
|
|
41
|
+
author: user
|
|
42
|
+
? {
|
|
43
|
+
handle: string(user.screen_name) ?? undefined,
|
|
44
|
+
name: string(user.name) ?? undefined,
|
|
45
|
+
verified: bool(user.verified) ?? bool(user.is_blue_verified),
|
|
46
|
+
}
|
|
47
|
+
: undefined,
|
|
48
|
+
createdAt: isoFromDateString(tweet.created_at),
|
|
49
|
+
likeCount: count(tweet.favorite_count),
|
|
50
|
+
commentCount: count(tweet.conversation_count),
|
|
51
|
+
shareCount: count(tweet.retweet_count),
|
|
52
|
+
viewCount: count(tweet.view_count),
|
|
53
|
+
nsfw: bool(tweet.possibly_sensitive),
|
|
54
|
+
extra: { lang: string(tweet.lang) ?? undefined },
|
|
55
|
+
};
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
// The public syndication endpoint returns tweet media (video variants + photos)
|
package/src/youtube.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
asUrl,
|
|
3
|
+
count,
|
|
3
4
|
filename,
|
|
5
|
+
isoFromDateString,
|
|
4
6
|
number,
|
|
5
7
|
object,
|
|
6
8
|
string,
|
|
9
|
+
type PostMetadata,
|
|
10
|
+
type YoutubeExtra,
|
|
7
11
|
type ResolveContext,
|
|
8
12
|
type Json,
|
|
9
13
|
type Net,
|
|
@@ -63,7 +67,32 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
|
|
|
63
67
|
url: streams.video,
|
|
64
68
|
...(streams.audio ? { audio: { headers, url: streams.audio } } : {}),
|
|
65
69
|
};
|
|
66
|
-
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], platform: "youtube" };
|
|
70
|
+
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function youtubeMetadata(payload: unknown): PostMetadata & { extra?: YoutubeExtra } {
|
|
74
|
+
const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
|
|
75
|
+
const renderer =
|
|
76
|
+
object(payload) && object(payload.microformat) && object(payload.microformat.playerMicroformatRenderer)
|
|
77
|
+
? payload.microformat.playerMicroformatRenderer
|
|
78
|
+
: null;
|
|
79
|
+
const author = details ? string(details.author) : null;
|
|
80
|
+
const keywords =
|
|
81
|
+
details && Array.isArray(details.keywords)
|
|
82
|
+
? details.keywords.filter((keyword): keyword is string => typeof keyword === "string")
|
|
83
|
+
: [];
|
|
84
|
+
return {
|
|
85
|
+
title: details ? string(details.title) ?? undefined : undefined,
|
|
86
|
+
text: details ? string(details.shortDescription) ?? undefined : undefined,
|
|
87
|
+
author: author ? { name: author } : undefined,
|
|
88
|
+
createdAt: renderer ? isoFromDateString(renderer.publishDate) : undefined,
|
|
89
|
+
viewCount: details ? count(details.viewCount) : undefined,
|
|
90
|
+
extra: {
|
|
91
|
+
channelId: details ? string(details.channelId) ?? undefined : undefined,
|
|
92
|
+
durationSeconds: details ? count(details.lengthSeconds) : undefined,
|
|
93
|
+
keywords: keywords.length > 0 ? keywords : undefined,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
// Adaptive H.264 video and AAC audio go up to 1080p with direct URLs; merging them
|