@postfetch/core 0.12.4 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2291,13 +2291,19 @@ async function resolveTwitter(input) {
2291
2291
  throw new Error("Tweet is unavailable or age-restricted");
2292
2292
  }
2293
2293
  await expandTwitterText(input.net, tweet, id);
2294
- const groups = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
2294
+ const posts = twitterTweets(tweet, id);
2295
+ const groups = posts.flatMap(({ id: postId2, tweet: post }) => {
2295
2296
  const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
2296
2297
  return media.flatMap((entry, index) => {
2297
2298
  const candidates = twitterCandidates(entry, postId2, index + 1);
2298
2299
  return candidates.length > 0 ? [candidates] : [];
2299
2300
  });
2300
2301
  });
2302
+ for (const { id: postId2, tweet: post } of posts.toReversed()) {
2303
+ const card = twitterCardImage(post, postId2);
2304
+ if (card)
2305
+ groups.push([{ bitrate: 0, item: card }]);
2306
+ }
2301
2307
  const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
2302
2308
  return { archiveFilename: filename(`twitter_${id}.zip`), comments: await twitterComments(input, id), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
2303
2309
  }
@@ -2449,6 +2455,48 @@ function twitterCandidates(entry, id, index) {
2449
2455
  const photo = string(entry.media_url_https);
2450
2456
  return photo ? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }] : [];
2451
2457
  }
2458
+ function twitterCardImage(tweet, id) {
2459
+ const card = object(tweet.card) ? tweet.card : null;
2460
+ if (!card || card.name !== "summary" && card.name !== "summary_large_image")
2461
+ return null;
2462
+ const bindings = object(card.binding_values) ? card.binding_values : {};
2463
+ const images = Object.entries(bindings).flatMap(([key, value]) => {
2464
+ if (!/^(photo_image_full_size|summary_photo_image|thumbnail_image)(_(original|x_large|large|small))?$/.test(key) || !object(value) || !object(value.image_value))
2465
+ return [];
2466
+ const image2 = value.image_value;
2467
+ const rawUrl = string(image2.url);
2468
+ if (!rawUrl)
2469
+ return [];
2470
+ try {
2471
+ const url = new URL(rawUrl);
2472
+ if (url.protocol !== "https:" && url.protocol !== "http:")
2473
+ return [];
2474
+ const format = (url.searchParams.get("format") ?? url.pathname.match(/\.(jpe?g|png|webp|gif)$/i)?.[1] ?? "jpg").toLowerCase();
2475
+ const extension = format === "jpeg" ? "jpg" : format;
2476
+ if (!["jpg", "png", "webp", "gif"].includes(extension))
2477
+ return [];
2478
+ return [{
2479
+ url: url.href,
2480
+ extension,
2481
+ original: key.endsWith("_original") ? 1 : 0,
2482
+ area: Math.max(0, number(image2.width) ?? 0) * Math.max(0, number(image2.height) ?? 0)
2483
+ }];
2484
+ } catch {
2485
+ return [];
2486
+ }
2487
+ }).sort((a, b) => b.original - a.original || b.area - a.area);
2488
+ const image = images[0];
2489
+ return image ? {
2490
+ filename: filename(`twitter_${id}_preview.${image.extension}`),
2491
+ headers: { "user-agent": browserUserAgent() },
2492
+ id,
2493
+ kind: "image",
2494
+ mime: `image/${image.extension === "jpg" ? "jpeg" : image.extension}`,
2495
+ platform: "twitter",
2496
+ role: "linkPreview",
2497
+ url: image.url
2498
+ } : null;
2499
+ }
2452
2500
  function videoVariants(entry) {
2453
2501
  const info = object(entry.video_info) ? entry.video_info : null;
2454
2502
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
@@ -33,6 +33,8 @@ export type MediaItem = {
33
33
  mime: string;
34
34
  /** The platform this item came from. */
35
35
  platform: Platform;
36
+ /** Website preview image, displayed below the post's content rather than in its attachment gallery. */
37
+ role?: "linkPreview";
36
38
  /** Direct CDN URL of the media. */
37
39
  url: string;
38
40
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
4
4
  "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/internal.ts CHANGED
@@ -32,6 +32,8 @@ export type MediaItem = {
32
32
  mime: string;
33
33
  /** The platform this item came from. */
34
34
  platform: Platform;
35
+ /** Website preview image, displayed below the post's content rather than in its attachment gallery. */
36
+ role?: "linkPreview";
35
37
  /** Direct CDN URL of the media. */
36
38
  url: string;
37
39
  };
package/src/twitter.ts CHANGED
@@ -33,13 +33,20 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
33
33
  throw new Error("Tweet is unavailable or age-restricted");
34
34
  }
35
35
  await expandTwitterText(input.net, tweet, id);
36
- const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
36
+ const posts = twitterTweets(tweet, id);
37
+ const groups = posts.flatMap(({ id: postId, tweet: post }) => {
37
38
  const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
38
39
  return media.flatMap((entry, index) => {
39
40
  const candidates = twitterCandidates(entry, postId, index + 1);
40
41
  return candidates.length > 0 ? [candidates] : [];
41
42
  });
42
43
  });
44
+ // Quotes nest inside their containing post, so append each card as we unwind
45
+ // the quote chain. Download order then matches the displayed post order.
46
+ for (const { id: postId, tweet: post } of posts.toReversed()) {
47
+ const card = twitterCardImage(post, postId);
48
+ if (card) groups.push([{ bitrate: 0, item: card }]);
49
+ }
43
50
  const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
44
51
  // A text-only tweet is a valid result with metadata and no media. Syndication
45
52
  // has already failed or returned a tombstone when the tweet is unavailable,
@@ -213,6 +220,41 @@ function twitterCandidates(entry: Json, id: string, index: number): TwitterCandi
213
220
  : [];
214
221
  }
215
222
 
223
+ function twitterCardImage(tweet: Json, id: string): MediaItem | null {
224
+ const card = object(tweet.card) ? tweet.card : null;
225
+ if (!card || (card.name !== "summary" && card.name !== "summary_large_image")) return null;
226
+ const bindings = object(card.binding_values) ? card.binding_values : {};
227
+ const images = Object.entries(bindings).flatMap(([key, value]) => {
228
+ // Select a single preview, excluding site icons and unrelated card assets.
229
+ if (!/^(photo_image_full_size|summary_photo_image|thumbnail_image)(_(original|x_large|large|small))?$/.test(key)
230
+ || !object(value) || !object(value.image_value)) return [];
231
+ const image = value.image_value;
232
+ const rawUrl = string(image.url);
233
+ if (!rawUrl) return [];
234
+ try {
235
+ const url = new URL(rawUrl);
236
+ if (url.protocol !== "https:" && url.protocol !== "http:") return [];
237
+ const format = (url.searchParams.get("format") ?? url.pathname.match(/\.(jpe?g|png|webp|gif)$/i)?.[1] ?? "jpg").toLowerCase();
238
+ const extension = format === "jpeg" ? "jpg" : format;
239
+ if (!["jpg", "png", "webp", "gif"].includes(extension)) return [];
240
+ return [{
241
+ url: url.href, extension,
242
+ original: key.endsWith("_original") ? 1 : 0,
243
+ area: Math.max(0, number(image.width) ?? 0) * Math.max(0, number(image.height) ?? 0),
244
+ }];
245
+ } catch {
246
+ return [];
247
+ }
248
+ }).sort((a, b) => b.original - a.original || b.area - a.area);
249
+ const image = images[0];
250
+ return image ? {
251
+ filename: filename(`twitter_${id}_preview.${image.extension}`),
252
+ headers: { "user-agent": browserUserAgent() },
253
+ id, kind: "image", mime: `image/${image.extension === "jpg" ? "jpeg" : image.extension}`,
254
+ platform: "twitter", role: "linkPreview", url: image.url,
255
+ } : null;
256
+ }
257
+
216
258
  function videoVariants(entry: Json): Array<{ bitrate: number; url: string }> {
217
259
  const info = object(entry.video_info) ? entry.video_info : null;
218
260
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];