@postfetch/core 0.12.0 → 0.12.2
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 +32 -10
- package/package.json +1 -1
- package/src/instagram.ts +18 -9
- package/src/twitter.ts +20 -2
package/dist/index.js
CHANGED
|
@@ -365,8 +365,9 @@ function embedHeaders() {
|
|
|
365
365
|
async function resolveInstagram(input) {
|
|
366
366
|
const code = shortcode(input.url);
|
|
367
367
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
368
|
+
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
368
369
|
const resolvers = [
|
|
369
|
-
() =>
|
|
370
|
+
async () => page.media,
|
|
370
371
|
() => mobileMedia(input.net, code, input.preferredWidth),
|
|
371
372
|
() => embedMedia(input.net, code),
|
|
372
373
|
() => graphqlMedia(input.net, code)
|
|
@@ -380,7 +381,7 @@ async function resolveInstagram(input) {
|
|
|
380
381
|
}
|
|
381
382
|
}
|
|
382
383
|
if (!media) {
|
|
383
|
-
throw await instagramUnavailable(input.net, code);
|
|
384
|
+
throw await instagramUnavailable(input.net, code, page.reason);
|
|
384
385
|
}
|
|
385
386
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
386
387
|
if (items.length === 0) {
|
|
@@ -388,12 +389,12 @@ async function resolveInstagram(input) {
|
|
|
388
389
|
}
|
|
389
390
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
390
391
|
}
|
|
391
|
-
async function instagramUnavailable(net, code) {
|
|
392
|
+
async function instagramUnavailable(net, code, pageReason) {
|
|
392
393
|
const url = new URL("https://i.instagram.com/api/v1/oembed/");
|
|
393
394
|
url.searchParams.set("url", `https://www.instagram.com/p/${code}/`);
|
|
394
395
|
const response = await net(url.href, { headers: mobileHeaders() }, 1).catch(() => null);
|
|
395
396
|
const body = response ? await response.text().catch(() => "") : "";
|
|
396
|
-
const reason = instagramUnavailableReason(response?.status ?? 0, body);
|
|
397
|
+
const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body);
|
|
397
398
|
const probe = response ? `oembed ${response.status}` : "oembed unreachable";
|
|
398
399
|
return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason} (${probe})`, reason);
|
|
399
400
|
}
|
|
@@ -407,7 +408,7 @@ function instagramUnavailableReason(status, body) {
|
|
|
407
408
|
if (status !== 0 && status < 400) {
|
|
408
409
|
return "unavailable";
|
|
409
410
|
}
|
|
410
|
-
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
411
|
+
if (/MIN_AGE_ACCOUNT|under 18|age[- ]restricted/i.test(body)) {
|
|
411
412
|
return "ageRestricted";
|
|
412
413
|
}
|
|
413
414
|
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
@@ -480,11 +481,15 @@ function shortcode(input) {
|
|
|
480
481
|
async function pageMedia(net, code, preferredWidth) {
|
|
481
482
|
const response = await net(`https://www.instagram.com/p/${code}/`, { headers: navigationHeaders() });
|
|
482
483
|
if (!response.ok) {
|
|
483
|
-
return null;
|
|
484
|
+
return { media: null };
|
|
484
485
|
}
|
|
485
486
|
const html = await response.text();
|
|
486
487
|
const media = inlineMedia(html, code);
|
|
487
|
-
|
|
488
|
+
const reason = media && object(media.gating_ruling) ? instagramUnavailableReason(400, JSON.stringify(media.gating_ruling)) : "unavailable";
|
|
489
|
+
return {
|
|
490
|
+
media: media && mediaItems(media, code, preferredWidth).length > 0 ? media : null,
|
|
491
|
+
reason: reason === "unavailable" ? undefined : reason
|
|
492
|
+
};
|
|
488
493
|
}
|
|
489
494
|
function inlineMedia(html, code) {
|
|
490
495
|
for (const match of html.matchAll(/<script type="application\/json"[^>]*>(.*?)<\/script>/gs)) {
|
|
@@ -515,7 +520,7 @@ function searchMedia(node, code) {
|
|
|
515
520
|
return null;
|
|
516
521
|
}
|
|
517
522
|
const hasMedia = Array.isArray(node.video_versions) || Array.isArray(node.carousel_media) || object(node.image_versions2) && Array.isArray(node.image_versions2.candidates);
|
|
518
|
-
if (hasMedia && node.code === code) {
|
|
523
|
+
if ((hasMedia || object(node.gating_ruling)) && node.code === code) {
|
|
519
524
|
return node;
|
|
520
525
|
}
|
|
521
526
|
for (const key in node) {
|
|
@@ -2315,7 +2320,7 @@ function twitterMetadata(tweet) {
|
|
|
2315
2320
|
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
2316
2321
|
}
|
|
2317
2322
|
return {
|
|
2318
|
-
text: twitterText(tweet) ?? undefined,
|
|
2323
|
+
text: cleanTwitterText(twitterText(tweet), tweet) ?? undefined,
|
|
2319
2324
|
author: user ? {
|
|
2320
2325
|
handle: string(user.screen_name) ?? undefined,
|
|
2321
2326
|
name: string(user.name) ?? undefined,
|
|
@@ -2339,6 +2344,23 @@ function noteText(tweet) {
|
|
|
2339
2344
|
function twitterText(tweet) {
|
|
2340
2345
|
return noteText(tweet) ?? string(tweet.full_text) ?? string(tweet.text);
|
|
2341
2346
|
}
|
|
2347
|
+
function cleanTwitterText(text, tweet) {
|
|
2348
|
+
if (text === null)
|
|
2349
|
+
return null;
|
|
2350
|
+
const entities = object(tweet.entities) ? tweet.entities : null;
|
|
2351
|
+
const media = [
|
|
2352
|
+
...entities && Array.isArray(entities.media) ? entities.media : [],
|
|
2353
|
+
...Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails : []
|
|
2354
|
+
];
|
|
2355
|
+
const urls = new Set(media.filter(object).map((entry) => string(entry.url)).filter(Boolean));
|
|
2356
|
+
while (true) {
|
|
2357
|
+
const trailing = text.match(/(?:^|\s)(https?:\/\/\S+)\s*$/);
|
|
2358
|
+
if (!trailing || !urls.has(trailing[1]))
|
|
2359
|
+
break;
|
|
2360
|
+
text = text.slice(0, trailing.index).trimEnd();
|
|
2361
|
+
}
|
|
2362
|
+
return text.replace(/&(?:gt|lt);/g, (entity) => entity === ">" ? ">" : "<");
|
|
2363
|
+
}
|
|
2342
2364
|
function needsFullText(tweet) {
|
|
2343
2365
|
if (noteText(tweet) || string(tweet.full_text)) {
|
|
2344
2366
|
return false;
|
|
@@ -2569,7 +2591,7 @@ function twitterComment(reply, id) {
|
|
|
2569
2591
|
url: `https://x.com/i/status/${id}`,
|
|
2570
2592
|
items,
|
|
2571
2593
|
metadata: {
|
|
2572
|
-
text: string(reply.text) ?? undefined,
|
|
2594
|
+
text: cleanTwitterText(string(reply.text), reply) ?? undefined,
|
|
2573
2595
|
author: {
|
|
2574
2596
|
handle: string(author2.screen_name) ?? undefined,
|
|
2575
2597
|
name: string(author2.name) ?? undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
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/instagram.ts
CHANGED
|
@@ -59,8 +59,9 @@ function embedHeaders(): Record<string, string> {
|
|
|
59
59
|
export async function resolveInstagram(input: ResolveContext): Promise<PostfetchResult> {
|
|
60
60
|
const code = shortcode(input.url);
|
|
61
61
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
62
|
+
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
62
63
|
const resolvers: Array<() => Promise<Json | null>> = [
|
|
63
|
-
() =>
|
|
64
|
+
async () => page.media,
|
|
64
65
|
() => mobileMedia(input.net, code, input.preferredWidth),
|
|
65
66
|
() => embedMedia(input.net, code),
|
|
66
67
|
() => graphqlMedia(input.net, code),
|
|
@@ -74,7 +75,7 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
if (!media) {
|
|
77
|
-
throw await instagramUnavailable(input.net, code);
|
|
78
|
+
throw await instagramUnavailable(input.net, code, page.reason);
|
|
78
79
|
}
|
|
79
80
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
80
81
|
if (items.length === 0) {
|
|
@@ -83,12 +84,14 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
83
84
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
async function instagramUnavailable(net: Net, code: string): Promise<PostfetchError> {
|
|
87
|
+
async function instagramUnavailable(net: Net, code: string, pageReason?: PostfetchReason): Promise<PostfetchError> {
|
|
87
88
|
const url = new URL("https://i.instagram.com/api/v1/oembed/");
|
|
88
89
|
url.searchParams.set("url", `https://www.instagram.com/p/${code}/`);
|
|
89
90
|
const response = await net(url.href, { headers: mobileHeaders() }, 1).catch(() => null);
|
|
90
91
|
const body = response ? await response.text().catch(() => "") : "";
|
|
91
|
-
|
|
92
|
+
// The page can identify an age gate even when oembed only reports a generic
|
|
93
|
+
// geoblock_required error with no MIN_AGE_ACCOUNT marker.
|
|
94
|
+
const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body);
|
|
92
95
|
// Carry the oembed status so an unclassified failure still leaves a raw clue
|
|
93
96
|
// (200 = post is public but no media could be parsed; 429 = throttled; …).
|
|
94
97
|
const probe = response ? `oembed ${response.status}` : "oembed unreachable";
|
|
@@ -109,7 +112,7 @@ export function instagramUnavailableReason(status: number, body: string): Postfe
|
|
|
109
112
|
if (status !== 0 && status < 400) {
|
|
110
113
|
return "unavailable";
|
|
111
114
|
}
|
|
112
|
-
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
115
|
+
if (/MIN_AGE_ACCOUNT|under 18|age[- ]restricted/i.test(body)) {
|
|
113
116
|
return "ageRestricted";
|
|
114
117
|
}
|
|
115
118
|
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
@@ -190,14 +193,20 @@ function shortcode(input: string): string {
|
|
|
190
193
|
return code;
|
|
191
194
|
}
|
|
192
195
|
|
|
193
|
-
async function pageMedia(net: Net, code: string, preferredWidth: number): Promise<Json | null> {
|
|
196
|
+
async function pageMedia(net: Net, code: string, preferredWidth: number): Promise<{ media: Json | null; reason?: PostfetchReason }> {
|
|
194
197
|
const response = await net(`https://www.instagram.com/p/${code}/`, { headers: navigationHeaders() });
|
|
195
198
|
if (!response.ok) {
|
|
196
|
-
return null;
|
|
199
|
+
return { media: null };
|
|
197
200
|
}
|
|
198
201
|
const html = await response.text();
|
|
199
202
|
const media = inlineMedia(html, code);
|
|
200
|
-
|
|
203
|
+
const reason = media && object(media.gating_ruling)
|
|
204
|
+
? instagramUnavailableReason(400, JSON.stringify(media.gating_ruling))
|
|
205
|
+
: "unavailable";
|
|
206
|
+
return {
|
|
207
|
+
media: media && mediaItems(media, code, preferredWidth).length > 0 ? media : null,
|
|
208
|
+
reason: reason === "unavailable" ? undefined : reason,
|
|
209
|
+
};
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
function inlineMedia(html: string, code: string): Json | null {
|
|
@@ -233,7 +242,7 @@ function searchMedia(node: unknown, code: string): Json | null {
|
|
|
233
242
|
Array.isArray(node.video_versions) ||
|
|
234
243
|
Array.isArray(node.carousel_media) ||
|
|
235
244
|
(object(node.image_versions2) && Array.isArray(node.image_versions2.candidates));
|
|
236
|
-
if (hasMedia && node.code === code) {
|
|
245
|
+
if ((hasMedia || object(node.gating_ruling)) && node.code === code) {
|
|
237
246
|
return node;
|
|
238
247
|
}
|
|
239
248
|
for (const key in node) {
|
package/src/twitter.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
67
67
|
extra.parentTweet = { id: parentId, metadata: twitterMetadata(parent) };
|
|
68
68
|
}
|
|
69
69
|
return {
|
|
70
|
-
text: twitterText(tweet) ?? undefined,
|
|
70
|
+
text: cleanTwitterText(twitterText(tweet), tweet) ?? undefined,
|
|
71
71
|
author: user
|
|
72
72
|
? {
|
|
73
73
|
handle: string(user.screen_name) ?? undefined,
|
|
@@ -96,6 +96,24 @@ function twitterText(tweet: Json): string | null {
|
|
|
96
96
|
return noteText(tweet) ?? string(tweet.full_text) ?? string(tweet.text);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// X serializes attachments as URLs in text. Only remove a trailing URL when
|
|
100
|
+
// the same post explicitly identifies it as media; ordinary links stay intact.
|
|
101
|
+
function cleanTwitterText(text: string | null, tweet: Json): string | null {
|
|
102
|
+
if (text === null) return null;
|
|
103
|
+
const entities = object(tweet.entities) ? tweet.entities : null;
|
|
104
|
+
const media = [
|
|
105
|
+
...(entities && Array.isArray(entities.media) ? entities.media : []),
|
|
106
|
+
...(Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails : []),
|
|
107
|
+
];
|
|
108
|
+
const urls = new Set(media.filter(object).map((entry) => string(entry.url)).filter(Boolean));
|
|
109
|
+
while (true) {
|
|
110
|
+
const trailing: RegExpMatchArray | null = text.match(/(?:^|\s)(https?:\/\/\S+)\s*$/);
|
|
111
|
+
if (!trailing || !urls.has(trailing[1])) break;
|
|
112
|
+
text = text.slice(0, trailing.index).trimEnd();
|
|
113
|
+
}
|
|
114
|
+
return text.replace(/&(?:gt|lt);/g, (entity) => entity === ">" ? ">" : "<");
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
function needsFullText(tweet: Json): boolean {
|
|
100
118
|
if (noteText(tweet) || string(tweet.full_text)) {
|
|
101
119
|
return false;
|
|
@@ -350,7 +368,7 @@ function twitterComment(reply: Json, id: string): PostComment {
|
|
|
350
368
|
url: `https://x.com/i/status/${id}`,
|
|
351
369
|
items,
|
|
352
370
|
metadata: {
|
|
353
|
-
text: string(reply.text) ?? undefined,
|
|
371
|
+
text: cleanTwitterText(string(reply.text), reply) ?? undefined,
|
|
354
372
|
author: {
|
|
355
373
|
handle: string(author.screen_name) ?? undefined,
|
|
356
374
|
name: string(author.name) ?? undefined,
|