@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 +49 -1
- package/dist/internal.d.ts +2 -0
- package/package.json +1 -1
- package/src/internal.ts +2 -0
- package/src/twitter.ts +43 -1
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
|
|
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) : [];
|
package/dist/internal.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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) : [];
|