@postfetch/core 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
3
3
  /** The kind of a {@link MediaItem}. */
4
4
  export type MediaKind = "audio" | "image" | "video";
5
5
  /** A single resolved media file: a direct URL plus everything needed to fetch and name it. */
@@ -99,6 +99,17 @@ export type TiktokExtra = {
99
99
  export type TwitterExtra = {
100
100
  /** BCP 47 language tag detected for the tweet. */
101
101
  lang?: string;
102
+ /** The post quoted by this post, when X includes it in the resolved payload. */
103
+ quotedTweet?: TwitterQuotedTweet;
104
+ };
105
+ /** A quoted X post and its normalized metadata. */
106
+ export type TwitterQuotedTweet = {
107
+ /** Status id of the quoted post. */
108
+ id: string;
109
+ /** Metadata belonging to the quoted post rather than the outer post. */
110
+ metadata: PostMetadata & {
111
+ extra?: TwitterExtra;
112
+ };
102
113
  };
103
114
  /** Instagram-specific metadata. */
104
115
  export type InstagramExtra = {
@@ -137,7 +148,7 @@ export type PinterestExtra = {
137
148
  outboundLink?: string;
138
149
  };
139
150
  /** 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>;
151
+ export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
141
152
  /** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
142
153
  export type PlatformResult<P extends Platform, Extra> = {
143
154
  /** Suggested filename when zipping every item. */
@@ -0,0 +1,3 @@
1
+ import { type Json, type PostMetadata, type PostfetchResult, type ResolveContext } from "./internal";
2
+ export declare function resolveLinkedin(input: ResolveContext): Promise<PostfetchResult>;
3
+ export declare function linkedinMetadata(post: Json): PostMetadata;
@@ -5,16 +5,24 @@ export type PostfetchOptions = {
5
5
  fetch?: typeof fetch;
6
6
  /** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
7
7
  preferredWidth?: number;
8
+ /**
9
+ * Soft file-size cap in bytes. The normal rendition is resolved and probed
10
+ * with HEAD; when it exceeds the cap, a smaller available rendition is
11
+ * returned. If size discovery or the smaller rendition is unavailable, the
12
+ * normal result is returned unchanged.
13
+ */
14
+ tryMaxBytes?: number;
8
15
  };
9
16
  /**
10
17
  * Resolve a public social post URL to its media.
11
18
  *
12
19
  * Detects the platform from the URL host, then returns the post's media as typed
13
20
  * {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
14
- * performs no side effects beyond the lookup; use {@link download}, {@link archive}
15
- * or {@link toResponse} to turn the result into bytes or a `Response`.
21
+ * performs no side effects beyond the lookup; use {@link download},
22
+ * {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
23
+ * into bytes, a `Blob` or a `Response`.
16
24
  *
17
- * @param url A post URL from Instagram, TikTok, YouTube, Facebook or X.
25
+ * @param url A post URL from a supported platform.
18
26
  * @param options See {@link PostfetchOptions}.
19
27
  * @returns The resolved post and its media items.
20
28
  * @throws {PostfetchError} If the URL is empty or its host is unsupported.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.5.2",
4
- "description": "Zero-dependency typed core that turns Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
3
+ "version": "0.7.0",
4
+ "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -30,6 +30,7 @@
30
30
  "downloader",
31
31
  "facebook",
32
32
  "instagram",
33
+ "linkedin",
33
34
  "media",
34
35
  "pinterest",
35
36
  "reddit",
package/src/download.ts CHANGED
@@ -54,6 +54,38 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
54
54
  return response;
55
55
  }
56
56
 
57
+ /**
58
+ * Download an already-resolved media URL as a Blob.
59
+ *
60
+ * This is useful for consumers that need to upload media themselves instead of
61
+ * passing its URL to a third party. When the resolved {@link MediaItem} carries
62
+ * required CDN headers, pass them as the second argument.
63
+ *
64
+ * @param url A direct media URL returned by a resolver.
65
+ * @param headers Headers required by the resolved media URL.
66
+ * @param options Fetch options.
67
+ * @returns The downloaded media as a Blob.
68
+ *
69
+ * @example Upload a video with FormData
70
+ * ```ts
71
+ * const [media] = (await postfetch(sourceUrl)).items;
72
+ * const blob = await downloadBlob(media.url, media.headers);
73
+ * form.append("video", blob, media.filename);
74
+ * ```
75
+ */
76
+ export async function downloadBlob(
77
+ url: string,
78
+ headers: HeadersInit = {},
79
+ options: DownloadOptions = {},
80
+ ): Promise<Blob> {
81
+ const net = createNet(options.fetch ?? globalThis.fetch);
82
+ const response = await net(url, { headers });
83
+ if (!response.ok || !response.body) {
84
+ throw new PostfetchError(502, `download failed: ${response.status}`);
85
+ }
86
+ return response.blob();
87
+ }
88
+
57
89
  /**
58
90
  * Download every item of a result and zip them in-process (store mode).
59
91
  *
package/src/facebook.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { asUrl, count, filename, isoFromEpochSeconds, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
1
+ import { asUrl, count, filename, isoFromEpochSeconds, PostfetchError, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
2
2
  import { browserUserAgent } from "./fingerprint";
3
3
 
4
4
  export async function resolveFacebook(input: ResolveContext): Promise<PostfetchResult> {
5
- const canonical = await canonicalUrl(input.net, input.url);
5
+ const canonical = await canonicalUrl(input.net, normalizedInputUrl(input.url));
6
6
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
7
7
  // The embed player only exposes /share/v/ videos; reels (and anything it
8
8
  // misses) come from the watch page, which the &_rdr flag serves logged-out.
9
- const embed = await embedVideo(input.net, canonical);
10
- const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id) : null;
9
+ const embed = await embedVideo(input.net, canonical, input.preferredWidth);
10
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
11
11
  const url = embed ?? watch?.url ?? null;
12
12
  if (!url) {
13
- throw new Error("Facebook video not found");
13
+ throw new PostfetchError(404, "Facebook video not found", "notFound");
14
14
  }
15
15
  const item: MediaItem = {
16
16
  filename: filename(`facebook_${id}.mp4`),
@@ -24,6 +24,16 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
24
24
  return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
25
25
  }
26
26
 
27
+ // Facebook share links are opaque shortlinks. Messaging apps sometimes append
28
+ // selected text to the copied URL as another path segment; Facebook returns 404
29
+ // for that form even though the share token is still intact. Strip everything
30
+ // after the token before following the redirect.
31
+ function normalizedInputUrl(input: string): string {
32
+ const url = asUrl(input);
33
+ const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
34
+ return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
35
+ }
36
+
27
37
  function navigationHeaders(): Record<string, string> {
28
38
  return {
29
39
  "accept-language": "en-US,en;q=0.9",
@@ -43,19 +53,19 @@ async function canonicalUrl(net: Net, input: string): Promise<string> {
43
53
 
44
54
  // The logged-out watch page is fingerprint-walled, but the public embed player
45
55
  // (plugins/video.php) still exposes the progressive hd_src/sd_src URLs.
46
- async function embedVideo(net: Net, canonical: string): Promise<string | null> {
56
+ async function embedVideo(net: Net, canonical: string, preferredWidth: number): Promise<string | null> {
47
57
  const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
48
58
  const response = await net(embed, { headers: navigationHeaders() });
49
59
  if (!response.ok) {
50
60
  return null;
51
61
  }
52
62
  const html = await response.text();
53
- return source(html, "hd_src") ?? source(html, "sd_src");
63
+ return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
54
64
  }
55
65
 
56
66
  // The logged-out watch page (with &_rdr) embeds the progressive video URLs in
57
67
  // its ScheduledServerJS blocks; these are the same fields yt-dlp reads.
58
- async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata: PostMetadata } | null> {
68
+ async function watchVideo(net: Net, id: string, preferredWidth: number): Promise<{ url: string; metadata: PostMetadata } | null> {
59
69
  // Without the document Accept header Facebook serves a JS shell without the
60
70
  // media JSON, so ask for HTML explicitly here.
61
71
  const headers = { ...navigationHeaders(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
@@ -64,14 +74,18 @@ async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata
64
74
  return null;
65
75
  }
66
76
  const html = await response.text();
67
- const url =
68
- source(html, "playable_url_quality_hd") ??
69
- source(html, "browser_native_hd_url") ??
70
- source(html, "playable_url") ??
71
- source(html, "browser_native_sd_url");
77
+ const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
78
+ const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
79
+ const url = preferredSource(preferredWidth, hd, sd);
72
80
  return url ? { url, metadata: watchMetadata(html) } : null;
73
81
  }
74
82
 
83
+ // Facebook labels its two progressive renditions as 720p HD and 360p SD. Pick
84
+ // the one closest to the requested width, preferring HD on the 540px midpoint.
85
+ function preferredSource(preferredWidth: number, hd: string | null, sd: string | null): string | null {
86
+ return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
87
+ }
88
+
75
89
  // Only the fields that can be read unambiguously from the flat page: the
76
90
  // caption (creation_story) and the reaction/comment/share counts appear in
77
91
  // shapes shared with related videos and comment threads, so they are skipped
@@ -32,6 +32,10 @@ export type BrowserFingerprint = {
32
32
  userAgent: string;
33
33
  };
34
34
 
35
+ export type NavigationHeaders = Record<string, string> & {
36
+ "user-agent": string;
37
+ };
38
+
35
39
  export function browserFingerprint(): BrowserFingerprint {
36
40
  const version = pick(chromeVersions);
37
41
  const platform = pick(desktopPlatforms);
@@ -43,20 +47,26 @@ export function browserFingerprint(): BrowserFingerprint {
43
47
  };
44
48
  }
45
49
 
46
- export function navigationHeaders(): Record<string, string> {
47
- const fingerprint = browserFingerprint();
50
+ function baseNavigationHeaders(userAgent: string, acceptLanguage: string): NavigationHeaders {
48
51
  return {
49
52
  accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
50
- "accept-language": fingerprint.acceptLanguage,
51
- "sec-ch-ua": fingerprint.secChUa,
52
- "sec-ch-ua-mobile": "?0",
53
- "sec-ch-ua-platform": fingerprint.secChUaPlatform,
53
+ "accept-language": acceptLanguage,
54
54
  "sec-fetch-dest": "document",
55
55
  "sec-fetch-mode": "navigate",
56
56
  "sec-fetch-site": "none",
57
57
  "sec-fetch-user": "?1",
58
58
  "upgrade-insecure-requests": "1",
59
- "user-agent": fingerprint.userAgent,
59
+ "user-agent": userAgent,
60
+ };
61
+ }
62
+
63
+ export function navigationHeaders(): NavigationHeaders {
64
+ const fingerprint = browserFingerprint();
65
+ return {
66
+ ...baseNavigationHeaders(fingerprint.userAgent, fingerprint.acceptLanguage),
67
+ "sec-ch-ua": fingerprint.secChUa,
68
+ "sec-ch-ua-mobile": "?0",
69
+ "sec-ch-ua-platform": fingerprint.secChUaPlatform,
60
70
  };
61
71
  }
62
72
 
@@ -70,6 +80,10 @@ export function firefoxUserAgent(): string {
70
80
  return `Mozilla/5.0 (${platform.uaToken}; rv:${version}.0) Gecko/20100101 Firefox/${version}.0`;
71
81
  }
72
82
 
83
+ export function firefoxNavigationHeaders(userAgent = firefoxUserAgent()): NavigationHeaders {
84
+ return baseNavigationHeaders(userAgent, pick(acceptLanguages));
85
+ }
86
+
73
87
  export function instagramAppUserAgent(): string {
74
88
  return pick(instagramAppUserAgents);
75
89
  }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Turn a public social post URL into its media.
3
3
  *
4
- * {@link postfetch} resolves Instagram, TikTok, YouTube, Facebook and X (Twitter)
4
+ * {@link postfetch} resolves Instagram, LinkedIn, TikTok, YouTube, Facebook and X (Twitter)
5
5
  * URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
6
6
  * (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
7
7
  * Cloudflare Workers and browsers alike. It only resolves the media; the
8
- * {@link download}, {@link archive} and {@link toResponse} helpers turn a result
9
- * into bytes or a `Response`.
8
+ * {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
9
+ * helpers turn a result into bytes, a `Blob`, or a `Response`.
10
10
  *
11
11
  * @example Resolve a reel and save the video
12
12
  * ```ts
@@ -20,7 +20,14 @@
20
20
  * @module
21
21
  */
22
22
  export { postfetch, detect, type PostfetchOptions } from "./postfetch";
23
- export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
23
+ export {
24
+ download,
25
+ downloadBlob,
26
+ archive,
27
+ toResponse,
28
+ type Archive,
29
+ type DownloadOptions,
30
+ } from "./download";
24
31
  export { PostfetchError } from "./internal";
25
32
  export type {
26
33
  InstagramExtra,
@@ -37,5 +44,6 @@ export type {
37
44
  SoundcloudExtra,
38
45
  TiktokExtra,
39
46
  TwitterExtra,
47
+ TwitterQuotedTweet,
40
48
  YoutubeExtra,
41
49
  } from "./internal";
package/src/instagram.ts CHANGED
@@ -58,11 +58,21 @@ function embedHeaders(): Record<string, string> {
58
58
 
59
59
  export async function resolveInstagram(input: ResolveContext): Promise<PostfetchResult> {
60
60
  const code = shortcode(input.url);
61
- const media =
62
- (await pageMedia(input.net, code, input.preferredWidth)) ??
63
- (await mobileMedia(input.net, code, input.preferredWidth)) ??
64
- (await embedMedia(input.net, code)) ??
65
- (await graphqlMedia(input.net, code));
61
+ const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
62
+ const resolvers: Array<() => Promise<Json | null>> = [
63
+ () => pageMedia(input.net, code, input.preferredWidth),
64
+ () => mobileMedia(input.net, code, input.preferredWidth),
65
+ () => embedMedia(input.net, code),
66
+ () => graphqlMedia(input.net, code),
67
+ ];
68
+ let media: Json | null = null;
69
+ for (const resolve of resolvers) {
70
+ const candidate = await resolve();
71
+ if (candidate && (!videoRequired || mediaItems(candidate, code, input.preferredWidth).some((item) => item.kind === "video"))) {
72
+ media = candidate;
73
+ break;
74
+ }
75
+ }
66
76
  if (!media) {
67
77
  throw await instagramUnavailable(input.net, code);
68
78
  }
@@ -301,23 +311,20 @@ async function graphqlMedia(net: Net, code: string): Promise<Json | null> {
301
311
  }
302
312
  const body = new URLSearchParams({
303
313
  ...params.body,
304
- doc_id: "8845758582119845",
314
+ av: "0",
315
+ doc_id: "26130443479876713",
305
316
  fb_api_caller_class: "RelayModern",
306
- fb_api_req_friendly_name: "PolarisPostActionLoadPostQueryQuery",
317
+ fb_api_req_friendly_name: "PolarisPostRootQuery",
307
318
  server_timestamps: "true",
308
- variables: JSON.stringify({
309
- fetch_tagged_user_count: null,
310
- hoisted_comment_id: null,
311
- hoisted_reply_id: null,
312
- shortcode: code,
313
- }),
319
+ variables: JSON.stringify({ shortcode: code }),
314
320
  });
315
321
  const response = await net("https://www.instagram.com/graphql/query", {
316
322
  body,
317
323
  headers: {
318
324
  ...embedHeaders(),
319
325
  ...params.headers,
320
- "X-FB-Friendly-Name": "PolarisPostActionLoadPostQueryQuery",
326
+ "X-FB-Friendly-Name": "PolarisPostRootQuery",
327
+ "X-Requested-With": "XMLHttpRequest",
321
328
  "content-type": "application/x-www-form-urlencoded",
322
329
  },
323
330
  method: "POST",
@@ -344,14 +351,14 @@ async function graphqlParams(net: Net, code: string): Promise<{ body: Record<str
344
351
  const push = entryObject("InstagramWebPushInfo", html);
345
352
  const lsd = entryObject("LSD", html)?.token ?? randomToken();
346
353
  const csrf = entryObject("InstagramSecurityConfig", html)?.csrf_token;
347
- const cookie = [
348
- csrf && `csrftoken=${csrf}`,
349
- polaris?.device_id && `ig_did=${polaris.device_id}`,
350
- polaris?.machine_id && `mid=${polaris.machine_id}`,
351
- "wd=1280x720",
352
- "dpr=2",
353
- "ig_nrcb=1",
354
- ].filter((value): value is string => typeof value === "string" && value.length > 0).join("; ");
354
+ const cookie = instagramCookies(response.headers, {
355
+ csrftoken: string(csrf),
356
+ dpr: "2",
357
+ ig_did: string(polaris?.device_id),
358
+ ig_nrcb: "1",
359
+ mid: string(polaris?.machine_id),
360
+ wd: "1280x720",
361
+ });
355
362
  return {
356
363
  headers: {
357
364
  "X-CSRFToken": string(csrf) ?? "",
@@ -386,6 +393,12 @@ async function graphqlParams(net: Net, code: string): Promise<{ body: Record<str
386
393
  }
387
394
 
388
395
  function gqlShortcodeMedia(data: Json): Json | null {
396
+ const webInfo = object(data.xdt_api__v1__media__shortcode__web_info) ? data.xdt_api__v1__media__shortcode__web_info : null;
397
+ const webItems = webInfo && Array.isArray(webInfo.items) ? webInfo.items : [];
398
+ const webMedia = webItems[0];
399
+ if (object(webMedia)) {
400
+ return webMedia;
401
+ }
389
402
  const media = data.gql_data && object(data.gql_data)
390
403
  ? data.gql_data.shortcode_media ?? data.gql_data.xdt_shortcode_media
391
404
  : data.shortcode_media ?? data.xdt_shortcode_media;
@@ -411,6 +424,25 @@ function mediaItems(media: Json, code: string, preferredWidth: number): MediaIte
411
424
  return instagramItem(media, code, null, preferredWidth);
412
425
  }
413
426
 
427
+ function instagramCookies(headers: Headers, fallback: Record<string, string | null>): string {
428
+ const getter = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
429
+ const raw = typeof getter === "function" ? getter.call(headers) : headers.get("set-cookie")?.split(/,(?=[^;]+?=)/) ?? [];
430
+ const cookies = new Map<string, string>();
431
+ for (const value of raw) {
432
+ const pair = value.split(";", 1)[0]?.trim();
433
+ const separator = pair?.indexOf("=") ?? -1;
434
+ if (pair && separator > 0) {
435
+ cookies.set(pair.slice(0, separator), pair.slice(separator + 1));
436
+ }
437
+ }
438
+ for (const [name, value] of Object.entries(fallback)) {
439
+ if (value && !cookies.has(name)) {
440
+ cookies.set(name, value);
441
+ }
442
+ }
443
+ return [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
444
+ }
445
+
414
446
  function instagramItem(media: Json, code: string, index: number | null, preferredWidth: number): MediaItem[] {
415
447
  const video = selectVersion(media.video_versions, preferredWidth) ?? string(media.video_url);
416
448
  const suffix = index === null ? "" : `_${index}`;
package/src/internal.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
3
3
 
4
4
  /** The kind of a {@link MediaItem}. */
5
5
  export type MediaKind = "audio" | "image" | "video";
@@ -103,6 +103,16 @@ export type TiktokExtra = {
103
103
  export type TwitterExtra = {
104
104
  /** BCP 47 language tag detected for the tweet. */
105
105
  lang?: string;
106
+ /** The post quoted by this post, when X includes it in the resolved payload. */
107
+ quotedTweet?: TwitterQuotedTweet;
108
+ };
109
+
110
+ /** A quoted X post and its normalized metadata. */
111
+ export type TwitterQuotedTweet = {
112
+ /** Status id of the quoted post. */
113
+ id: string;
114
+ /** Metadata belonging to the quoted post rather than the outer post. */
115
+ metadata: PostMetadata & { extra?: TwitterExtra };
106
116
  };
107
117
 
108
118
  /** Instagram-specific metadata. */
@@ -149,6 +159,7 @@ export type PinterestExtra = {
149
159
  export type PostfetchResult =
150
160
  | PlatformResult<"facebook", never>
151
161
  | PlatformResult<"instagram", InstagramExtra>
162
+ | PlatformResult<"linkedin", never>
152
163
  | PlatformResult<"pinterest", PinterestExtra>
153
164
  | PlatformResult<"reddit", RedditExtra>
154
165
  | PlatformResult<"soundcloud", SoundcloudExtra>