@postfetch/core 0.12.1 → 0.12.3

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/hls.d.ts CHANGED
@@ -12,5 +12,7 @@ export type HlsMaster = {
12
12
  audio: Record<string, string>;
13
13
  };
14
14
  export declare function isMasterPlaylist(text: string): boolean;
15
- export declare function parseMaster(text: string, baseUrl: string): HlsMaster;
15
+ export declare function parseMaster(text: string, baseUrl: string, options?: {
16
+ preferOriginalAudio?: boolean;
17
+ }): HlsMaster;
16
18
  export declare function assembleHls(net: Net, playlistUrl: string, headers: HeadersInit): Promise<Uint8Array>;
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
- () => pageMedia(input.net, code, input.preferredWidth),
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
- return media && mediaItems(media, code, preferredWidth).length > 0 ? media : null;
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) {
@@ -1213,11 +1218,11 @@ function mediaFragment(fragment, sequence) {
1213
1218
  function isMasterPlaylist(text) {
1214
1219
  return text.includes("#EXT-X-STREAM-INF");
1215
1220
  }
1216
- function parseMaster(text, baseUrl) {
1221
+ function parseMaster(text, baseUrl, options = {}) {
1217
1222
  const lines = text.split(`
1218
1223
  `).map((line) => line.trim());
1219
1224
  const audio = {};
1220
- const defaultAudio = new Set;
1225
+ const audioPreference = {};
1221
1226
  const variants = [];
1222
1227
  for (let index = 0;index < lines.length; index += 1) {
1223
1228
  const line = lines[index];
@@ -1225,10 +1230,12 @@ function parseMaster(text, baseUrl) {
1225
1230
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
1226
1231
  const uri = line.match(/URI="([^"]+)"/)?.[1];
1227
1232
  const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
1228
- if (group && uri && (!audio[group] || isDefault && !defaultAudio.has(group))) {
1233
+ const name = line.match(/(?:^|,)NAME="([^"]+)"/)?.[1] ?? "";
1234
+ const isOriginal = options.preferOriginalAudio && /\boriginal\b/i.test(name);
1235
+ const preference = isOriginal ? 2 : isDefault ? 1 : 0;
1236
+ if (group && uri && (!audio[group] || preference > audioPreference[group])) {
1229
1237
  audio[group] = new URL(uri, baseUrl).href;
1230
- if (isDefault)
1231
- defaultAudio.add(group);
1238
+ audioPreference[group] = preference;
1232
1239
  }
1233
1240
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
1234
1241
  const target = lines[index + 1];
@@ -2706,7 +2713,7 @@ async function resolveVisionOs(input, id, visitorData) {
2706
2713
  if (!response.ok) {
2707
2714
  throw new Error(`YouTube HLS manifest failed: ${response.status}`);
2708
2715
  }
2709
- const master = parseMaster(await response.text(), manifestUrl);
2716
+ const master = parseMaster(await response.text(), manifestUrl, { preferOriginalAudio: true });
2710
2717
  const variants = master.variants.filter((variant2) => /(?:^|,)(?:vp09|av01)\./i.test(variant2.codecs ?? "") && /(?:^|,)mp4a\.40\.2(?:,|$)/i.test(variant2.codecs ?? "") && variant2.audioGroup && master.audio[variant2.audioGroup]).sort((left, right) => Math.abs(left.width - input.preferredWidth) - Math.abs(right.width - input.preferredWidth) || right.bandwidth - left.bandwidth);
2711
2718
  const variant = variants[0];
2712
2719
  if (!variant?.audioGroup) {
@@ -2775,10 +2782,21 @@ function bestAudio(formats) {
2775
2782
  if (!current) {
2776
2783
  return format;
2777
2784
  }
2785
+ const preference = audioPreference(format) - audioPreference(current);
2786
+ if (preference !== 0) {
2787
+ return preference > 0 ? format : current;
2788
+ }
2778
2789
  return (number(format.bitrate) ?? 0) > (number(current.bitrate) ?? 0) ? format : current;
2779
2790
  }, null);
2780
2791
  return best ? string(best.url) : null;
2781
2792
  }
2793
+ function audioPreference(format) {
2794
+ const track = object(format.audioTrack) ? format.audioTrack : null;
2795
+ if (track && /\boriginal\b/i.test(string(track.displayName) ?? "")) {
2796
+ return 2;
2797
+ }
2798
+ return track?.audioIsDefault === true ? 1 : 0;
2799
+ }
2782
2800
  function playerBody(id, session) {
2783
2801
  return {
2784
2802
  contentCheckOk: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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/hls.ts CHANGED
@@ -16,10 +16,10 @@ export function isMasterPlaylist(text: string): boolean {
16
16
  return text.includes("#EXT-X-STREAM-INF");
17
17
  }
18
18
 
19
- export function parseMaster(text: string, baseUrl: string): HlsMaster {
19
+ export function parseMaster(text: string, baseUrl: string, options: { preferOriginalAudio?: boolean } = {}): HlsMaster {
20
20
  const lines = text.split("\n").map((line) => line.trim());
21
21
  const audio: Record<string, string> = {};
22
- const defaultAudio = new Set<string>();
22
+ const audioPreference: Record<string, number> = {};
23
23
  const variants: HlsVariant[] = [];
24
24
  for (let index = 0; index < lines.length; index += 1) {
25
25
  const line = lines[index];
@@ -27,9 +27,12 @@ export function parseMaster(text: string, baseUrl: string): HlsMaster {
27
27
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
28
28
  const uri = line.match(/URI="([^"]+)"/)?.[1];
29
29
  const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
30
- if (group && uri && (!audio[group] || (isDefault && !defaultAudio.has(group)))) {
30
+ const name = line.match(/(?:^|,)NAME="([^"]+)"/)?.[1] ?? "";
31
+ const isOriginal = options.preferOriginalAudio && /\boriginal\b/i.test(name);
32
+ const preference = isOriginal ? 2 : isDefault ? 1 : 0;
33
+ if (group && uri && (!audio[group] || preference > audioPreference[group])) {
31
34
  audio[group] = new URL(uri, baseUrl).href;
32
- if (isDefault) defaultAudio.add(group);
35
+ audioPreference[group] = preference;
33
36
  }
34
37
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
35
38
  const target = lines[index + 1];
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
- () => pageMedia(input.net, code, input.preferredWidth),
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
- const reason = instagramUnavailableReason(response?.status ?? 0, body);
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
- return media && mediaItems(media, code, preferredWidth).length > 0 ? media : null;
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/youtube.ts CHANGED
@@ -139,7 +139,7 @@ async function resolveVisionOs(input: ResolveContext, id: string, visitorData?:
139
139
  if (!response.ok) {
140
140
  throw new Error(`YouTube HLS manifest failed: ${response.status}`);
141
141
  }
142
- const master = parseMaster(await response.text(), manifestUrl);
142
+ const master = parseMaster(await response.text(), manifestUrl, { preferOriginalAudio: true });
143
143
  // YouTube's AVC HLS variants use MPEG-TS. VP9/AV1 variants use fMP4 and
144
144
  // work with the bundled box-level merger. Require the paired AAC-LC track.
145
145
  const variants = master.variants.filter((variant) =>
@@ -230,11 +230,25 @@ function bestAudio(formats: Json[]): string | null {
230
230
  if (!current) {
231
231
  return format;
232
232
  }
233
+ // YouTube's default can be a dub, and bitrate varies between languages.
234
+ // Select the original track before comparing audio quality.
235
+ const preference = audioPreference(format) - audioPreference(current);
236
+ if (preference !== 0) {
237
+ return preference > 0 ? format : current;
238
+ }
233
239
  return (number(format.bitrate) ?? 0) > (number(current.bitrate) ?? 0) ? format : current;
234
240
  }, null);
235
241
  return best ? string(best.url) : null;
236
242
  }
237
243
 
244
+ function audioPreference(format: Json): number {
245
+ const track = object(format.audioTrack) ? format.audioTrack : null;
246
+ if (track && /\boriginal\b/i.test(string(track.displayName) ?? "")) {
247
+ return 2;
248
+ }
249
+ return track?.audioIsDefault === true ? 1 : 0;
250
+ }
251
+
238
252
  function playerBody(id: string, session: YoutubeSession): Json {
239
253
  return {
240
254
  contentCheckOk: true,