@postfetch/core 0.4.0 → 0.4.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.d.ts +1 -1
- package/dist/index.js +48 -2
- package/dist/instagram.d.ts +2 -1
- package/dist/internal.d.ts +12 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/instagram.ts +56 -1
- package/src/internal.ts +17 -0
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 { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, YoutubeExtra, } 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
|
-
|
|
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
|
}
|
|
@@ -250,7 +252,7 @@ async function resolveInstagram(input) {
|
|
|
250
252
|
const code = shortcode(input.url);
|
|
251
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);
|
|
252
254
|
if (!media) {
|
|
253
|
-
throw
|
|
255
|
+
throw await instagramUnavailable(input.net, code);
|
|
254
256
|
}
|
|
255
257
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
256
258
|
if (items.length === 0) {
|
|
@@ -258,6 +260,50 @@ async function resolveInstagram(input) {
|
|
|
258
260
|
}
|
|
259
261
|
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
260
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
|
+
const probe = response ? `oembed ${response.status}` : "oembed unreachable";
|
|
270
|
+
return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason} (${probe})`, reason);
|
|
271
|
+
}
|
|
272
|
+
function instagramUnavailableReason(status, body) {
|
|
273
|
+
if (status === 404) {
|
|
274
|
+
return "notFound";
|
|
275
|
+
}
|
|
276
|
+
if (status === 429) {
|
|
277
|
+
return "rateLimited";
|
|
278
|
+
}
|
|
279
|
+
if (status !== 0 && status < 400) {
|
|
280
|
+
return "unavailable";
|
|
281
|
+
}
|
|
282
|
+
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
283
|
+
return "ageRestricted";
|
|
284
|
+
}
|
|
285
|
+
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
286
|
+
return "private";
|
|
287
|
+
}
|
|
288
|
+
if (/log in|login_required|not logged/i.test(body)) {
|
|
289
|
+
return "loginRequired";
|
|
290
|
+
}
|
|
291
|
+
return "unavailable";
|
|
292
|
+
}
|
|
293
|
+
function reasonStatus(reason) {
|
|
294
|
+
switch (reason) {
|
|
295
|
+
case "ageRestricted":
|
|
296
|
+
return 451;
|
|
297
|
+
case "private":
|
|
298
|
+
return 403;
|
|
299
|
+
case "loginRequired":
|
|
300
|
+
return 401;
|
|
301
|
+
case "rateLimited":
|
|
302
|
+
return 429;
|
|
303
|
+
default:
|
|
304
|
+
return 404;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
261
307
|
function instagramMetadata(media) {
|
|
262
308
|
const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
|
|
263
309
|
const countsHidden = bool(media.like_and_view_counts_disabled);
|
package/dist/instagram.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type InstagramExtra, type PostMetadata, type ResolveContext, type Json, 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;
|
|
3
4
|
export declare function instagramMetadata(media: Json): PostMetadata & {
|
|
4
5
|
extra?: InstagramExtra;
|
|
5
6
|
};
|
package/dist/internal.d.ts
CHANGED
|
@@ -160,19 +160,30 @@ export type ResolveContext = {
|
|
|
160
160
|
preferredWidth: number;
|
|
161
161
|
url: string;
|
|
162
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";
|
|
163
169
|
/** An error carrying an HTTP status, so adapters can map failures onto responses. */
|
|
164
170
|
export declare class PostfetchError extends Error {
|
|
165
171
|
/** HTTP status that best describes the failure. */
|
|
166
172
|
readonly status: number;
|
|
173
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
174
|
+
readonly reason?: PostfetchReason | undefined;
|
|
167
175
|
/**
|
|
168
176
|
* Create an error tagged with an HTTP status.
|
|
169
177
|
*
|
|
170
178
|
* @param status HTTP status that best describes the failure.
|
|
171
179
|
* @param message Human-readable message.
|
|
180
|
+
* @param reason Machine-readable cause, when the platform makes it knowable.
|
|
172
181
|
*/
|
|
173
182
|
constructor(
|
|
174
183
|
/** HTTP status that best describes the failure. */
|
|
175
|
-
status: number, message: string
|
|
184
|
+
status: number, message: string,
|
|
185
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
186
|
+
reason?: PostfetchReason | undefined);
|
|
176
187
|
}
|
|
177
188
|
export type Json = Record<string, unknown>;
|
|
178
189
|
export declare function createNet(baseFetch?: typeof fetch): Net;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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
package/src/instagram.ts
CHANGED
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
isoFromEpochSeconds,
|
|
7
7
|
number,
|
|
8
8
|
object,
|
|
9
|
+
PostfetchError,
|
|
9
10
|
string,
|
|
10
11
|
type InstagramExtra,
|
|
11
12
|
type PostMetadata,
|
|
13
|
+
type PostfetchReason,
|
|
12
14
|
type ResolveContext,
|
|
13
15
|
type Net,
|
|
14
16
|
type Json,
|
|
@@ -62,7 +64,7 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
62
64
|
(await embedMedia(input.net, code)) ??
|
|
63
65
|
(await graphqlMedia(input.net, code));
|
|
64
66
|
if (!media) {
|
|
65
|
-
throw
|
|
67
|
+
throw await instagramUnavailable(input.net, code);
|
|
66
68
|
}
|
|
67
69
|
const items = mediaItems(media, code, input.preferredWidth);
|
|
68
70
|
if (items.length === 0) {
|
|
@@ -71,6 +73,59 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
71
73
|
return { archiveFilename: filename(`instagram_${code}.zip`), id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
72
74
|
}
|
|
73
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
|
+
// Carry the oembed status so an unclassified failure still leaves a raw clue
|
|
83
|
+
// (200 = post is public but no media could be parsed; 429 = throttled; …).
|
|
84
|
+
const probe = response ? `oembed ${response.status}` : "oembed unreachable";
|
|
85
|
+
return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason} (${probe})`, reason);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Classifies why the post is gone from the oembed endpoint, which returns a
|
|
89
|
+
// telling status and body (404 when removed; 429 when throttled; 400 with a
|
|
90
|
+
// MIN_AGE_ACCOUNT block for 18+ accounts) while the media endpoints just come
|
|
91
|
+
// back empty.
|
|
92
|
+
export function instagramUnavailableReason(status: number, body: string): PostfetchReason {
|
|
93
|
+
if (status === 404) {
|
|
94
|
+
return "notFound";
|
|
95
|
+
}
|
|
96
|
+
if (status === 429) {
|
|
97
|
+
return "rateLimited";
|
|
98
|
+
}
|
|
99
|
+
if (status !== 0 && status < 400) {
|
|
100
|
+
return "unavailable";
|
|
101
|
+
}
|
|
102
|
+
if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
|
|
103
|
+
return "ageRestricted";
|
|
104
|
+
}
|
|
105
|
+
if (/private account|"is_private":\s*true|account is private/i.test(body)) {
|
|
106
|
+
return "private";
|
|
107
|
+
}
|
|
108
|
+
if (/log in|login_required|not logged/i.test(body)) {
|
|
109
|
+
return "loginRequired";
|
|
110
|
+
}
|
|
111
|
+
return "unavailable";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reasonStatus(reason: PostfetchReason): number {
|
|
115
|
+
switch (reason) {
|
|
116
|
+
case "ageRestricted":
|
|
117
|
+
return 451;
|
|
118
|
+
case "private":
|
|
119
|
+
return 403;
|
|
120
|
+
case "loginRequired":
|
|
121
|
+
return 401;
|
|
122
|
+
case "rateLimited":
|
|
123
|
+
return 429;
|
|
124
|
+
default:
|
|
125
|
+
return 404;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
74
129
|
export function instagramMetadata(media: Json): PostMetadata & { extra?: InstagramExtra } {
|
|
75
130
|
const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
|
|
76
131
|
const countsHidden = bool(media.like_and_view_counts_disabled);
|
package/src/internal.ts
CHANGED
|
@@ -181,6 +181,20 @@ export type ResolveContext = {
|
|
|
181
181
|
url: string;
|
|
182
182
|
};
|
|
183
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
|
+
|
|
184
198
|
/** An error carrying an HTTP status, so adapters can map failures onto responses. */
|
|
185
199
|
export class PostfetchError extends Error {
|
|
186
200
|
/**
|
|
@@ -188,11 +202,14 @@ export class PostfetchError extends Error {
|
|
|
188
202
|
*
|
|
189
203
|
* @param status HTTP status that best describes the failure.
|
|
190
204
|
* @param message Human-readable message.
|
|
205
|
+
* @param reason Machine-readable cause, when the platform makes it knowable.
|
|
191
206
|
*/
|
|
192
207
|
constructor(
|
|
193
208
|
/** HTTP status that best describes the failure. */
|
|
194
209
|
readonly status: number,
|
|
195
210
|
message: string,
|
|
211
|
+
/** Machine-readable cause, when the platform makes it knowable. */
|
|
212
|
+
readonly reason?: PostfetchReason,
|
|
196
213
|
) {
|
|
197
214
|
super(message);
|
|
198
215
|
this.name = "PostfetchError";
|