@postfetch/core 0.4.0 → 0.4.1

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 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
- constructor(status, message) {
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 new Error("Instagram media not found");
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,46 @@ 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
+ return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason}`, reason);
270
+ }
271
+ function instagramUnavailableReason(status, body) {
272
+ if (status === 404) {
273
+ return "notFound";
274
+ }
275
+ if (status !== 0 && status < 400) {
276
+ return "unavailable";
277
+ }
278
+ if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
279
+ return "ageRestricted";
280
+ }
281
+ if (/private account|"is_private":\s*true|account is private/i.test(body)) {
282
+ return "private";
283
+ }
284
+ if (/log in|login_required|not logged/i.test(body)) {
285
+ return "loginRequired";
286
+ }
287
+ return "unavailable";
288
+ }
289
+ function reasonStatus(reason) {
290
+ switch (reason) {
291
+ case "ageRestricted":
292
+ return 451;
293
+ case "private":
294
+ return 403;
295
+ case "loginRequired":
296
+ return 401;
297
+ case "rateLimited":
298
+ return 429;
299
+ default:
300
+ return 404;
301
+ }
302
+ }
261
303
  function instagramMetadata(media) {
262
304
  const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
263
305
  const countsHidden = bool(media.like_and_view_counts_disabled);
@@ -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
  };
@@ -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.0",
3
+ "version": "0.4.1",
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
@@ -30,6 +30,7 @@ export type {
30
30
  Platform,
31
31
  PlatformResult,
32
32
  PostAuthor,
33
+ PostfetchReason,
33
34
  PostfetchResult,
34
35
  PostMetadata,
35
36
  RedditExtra,
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 new Error("Instagram media not found");
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,52 @@ 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
+ return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason}`, reason);
83
+ }
84
+
85
+ // Classifies why the post is gone from the oembed endpoint, which returns a
86
+ // telling status and body (404 when removed; 400 with a MIN_AGE_ACCOUNT block
87
+ // for 18+ accounts) while the media endpoints just come back empty.
88
+ export function instagramUnavailableReason(status: number, body: string): PostfetchReason {
89
+ if (status === 404) {
90
+ return "notFound";
91
+ }
92
+ if (status !== 0 && status < 400) {
93
+ return "unavailable";
94
+ }
95
+ if (/MIN_AGE_ACCOUNT|under 18/i.test(body)) {
96
+ return "ageRestricted";
97
+ }
98
+ if (/private account|"is_private":\s*true|account is private/i.test(body)) {
99
+ return "private";
100
+ }
101
+ if (/log in|login_required|not logged/i.test(body)) {
102
+ return "loginRequired";
103
+ }
104
+ return "unavailable";
105
+ }
106
+
107
+ function reasonStatus(reason: PostfetchReason): number {
108
+ switch (reason) {
109
+ case "ageRestricted":
110
+ return 451;
111
+ case "private":
112
+ return 403;
113
+ case "loginRequired":
114
+ return 401;
115
+ case "rateLimited":
116
+ return 429;
117
+ default:
118
+ return 404;
119
+ }
120
+ }
121
+
74
122
  export function instagramMetadata(media: Json): PostMetadata & { extra?: InstagramExtra } {
75
123
  const user = object(media.user) ? media.user : object(media.owner) ? media.owner : null;
76
124
  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";