@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 +3 -1
- package/dist/index.js +32 -14
- package/package.json +1 -1
- package/src/hls.ts +7 -4
- package/src/instagram.ts +18 -9
- package/src/youtube.ts +15 -1
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
|
|
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
|
-
() =>
|
|
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) {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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/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,
|