@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 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
- constructor(status, message) {
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 new Error("Instagram media not found");
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, count, centralSize, centralStart) {
2116
+ function writeEnd(view, offset, count2, centralSize, centralStart) {
1900
2117
  view.setUint32(offset, 101010256, true);
1901
- view.setUint16(offset + 8, count, true);
1902
- view.setUint16(offset + 10, count, true);
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
  }
@@ -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
+ };
@@ -36,8 +36,110 @@ export type MediaItem = {
36
36
  /** Direct CDN URL of the media. */
37
37
  url: string;
38
38
  };
39
- /** The result of resolving a post: its platform, id and one or more media items. */
40
- export type PostfetchResult = {
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: 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;
@@ -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
+ };
@@ -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.1",
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 { MediaItem, MediaKind, Platform, PostfetchResult } from "./internal";
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 new Error("Instagram media not found");
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 result of resolving a post: its platform, id and one or more media items. */
40
- export type PostfetchResult = {
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: 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