@postfetch/core 0.13.4 → 0.13.6
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/audio-slider.d.ts +3 -2
- package/dist/index.js +155 -10
- package/dist/internal.d.ts +9 -0
- package/package.json +1 -1
- package/src/audio-slider.ts +21 -2
- package/src/instagram.ts +55 -10
- package/src/internal.ts +6 -0
- package/src/reddit.ts +77 -0
package/dist/audio-slider.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export type AudioSliderVideoOptions = DownloadOptions & {
|
|
|
18
18
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
19
19
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
20
20
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
21
|
-
* audio is discarded. The final audio item
|
|
22
|
-
*
|
|
21
|
+
* audio is discarded. The final audio item's optional `clip` selects the attached
|
|
22
|
+
* segment; otherwise the whole track plays. When the minimum slideshow duration
|
|
23
|
+
* is longer, that segment/track loops and is trimmed to the result.
|
|
23
24
|
*
|
|
24
25
|
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
25
26
|
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
package/dist/index.js
CHANGED
|
@@ -381,9 +381,11 @@ async function resolveInstagram(input) {
|
|
|
381
381
|
const code = shortcode(input.url);
|
|
382
382
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
383
383
|
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
384
|
+
let mobile;
|
|
385
|
+
const getMobile = () => mobile ??= mobileMedia(input.net, code, input.preferredWidth, page.media);
|
|
384
386
|
const resolvers = [
|
|
385
387
|
async () => page.media,
|
|
386
|
-
|
|
388
|
+
getMobile,
|
|
387
389
|
() => embedMedia(input.net, code),
|
|
388
390
|
() => graphqlMedia(input.net, code)
|
|
389
391
|
];
|
|
@@ -402,6 +404,13 @@ async function resolveInstagram(input) {
|
|
|
402
404
|
if (items.length === 0) {
|
|
403
405
|
throw new Error("Instagram media url not found");
|
|
404
406
|
}
|
|
407
|
+
if (soundtrackEligible(items) && !items.some((item) => item.kind === "audio") && media.music_metadata === undefined && !object(media.clips_metadata)) {
|
|
408
|
+
const richer = await getMobile().catch(() => null);
|
|
409
|
+
const samePost = richer && (string(richer.code) ? richer.code === code : string(richer.pk) !== null && string(richer.pk) === string(media.pk));
|
|
410
|
+
const audio = samePost ? instagramAudio(richer, code) : null;
|
|
411
|
+
if (audio)
|
|
412
|
+
items.push(audio);
|
|
413
|
+
}
|
|
405
414
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
406
415
|
}
|
|
407
416
|
async function instagramUnavailable(net, code, pageReason) {
|
|
@@ -549,8 +558,8 @@ function searchMedia(node, code) {
|
|
|
549
558
|
}
|
|
550
559
|
return null;
|
|
551
560
|
}
|
|
552
|
-
async function mobileMedia(net, code, preferredWidth) {
|
|
553
|
-
const id = await mediaId(net, code);
|
|
561
|
+
async function mobileMedia(net, code, preferredWidth, hint) {
|
|
562
|
+
const id = string(hint?.pk) ?? string(hint?.id) ?? await mediaId(net, code);
|
|
554
563
|
if (!id) {
|
|
555
564
|
return null;
|
|
556
565
|
}
|
|
@@ -706,15 +715,42 @@ function mediaItems(media, code, preferredWidth) {
|
|
|
706
715
|
const node = object(edge) && object(edge.node) ? edge.node : null;
|
|
707
716
|
return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
|
|
708
717
|
});
|
|
709
|
-
if (oldItems.length > 0) {
|
|
710
|
-
return oldItems;
|
|
711
|
-
}
|
|
712
718
|
const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
|
|
713
719
|
const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
720
|
+
const items = oldItems.length > 0 ? oldItems : newItems.length > 0 ? newItems : instagramItem(media, code, null, preferredWidth);
|
|
721
|
+
const audio = soundtrackEligible(items) ? instagramAudio(media, code) : null;
|
|
722
|
+
return audio ? [...items, audio] : items;
|
|
723
|
+
}
|
|
724
|
+
function soundtrackEligible(items) {
|
|
725
|
+
return items.length > 1 || items[0]?.kind === "image";
|
|
726
|
+
}
|
|
727
|
+
function instagramAudio(media, code) {
|
|
728
|
+
const metadata = object(media.music_metadata) ? media.music_metadata : object(media.clips_metadata) ? media.clips_metadata : null;
|
|
729
|
+
if (!metadata)
|
|
730
|
+
return null;
|
|
731
|
+
const info = object(metadata.music_info) ? metadata.music_info : null;
|
|
732
|
+
const consumption = info && object(info.music_consumption_info) ? info.music_consumption_info : null;
|
|
733
|
+
if (consumption?.should_mute_audio === true)
|
|
734
|
+
return null;
|
|
735
|
+
const asset = info && object(info.music_asset_info) ? info.music_asset_info : null;
|
|
736
|
+
const original = object(metadata.original_sound_info) ? metadata.original_sound_info : null;
|
|
737
|
+
const selected = [asset, original].find((value) => value && (string(value.progressive_download_url) ?? string(value.fast_start_progressive_download_url)));
|
|
738
|
+
const url = selected && (string(selected.progressive_download_url) ?? string(selected.fast_start_progressive_download_url));
|
|
739
|
+
if (!url || !/^https?:\/\//i.test(url))
|
|
740
|
+
return null;
|
|
741
|
+
const start = number(consumption?.audio_asset_start_time_in_ms);
|
|
742
|
+
const duration = number(consumption?.overlap_duration_in_ms);
|
|
743
|
+
const clip = start !== null && start >= 0 || duration !== null && duration > 0 ? { start: Math.max(0, start ?? 0) / 1000, ...duration !== null && duration > 0 ? { duration: duration / 1000 } : {} } : undefined;
|
|
744
|
+
return {
|
|
745
|
+
filename: filename(`instagram_${code}_audio.m4a`),
|
|
746
|
+
headers: { "user-agent": browserUserAgent() },
|
|
747
|
+
id: code,
|
|
748
|
+
kind: "audio",
|
|
749
|
+
mime: "audio/mp4",
|
|
750
|
+
platform: "instagram",
|
|
751
|
+
url,
|
|
752
|
+
...clip && { clip }
|
|
753
|
+
};
|
|
718
754
|
}
|
|
719
755
|
function instagramCookies(headers, fallback) {
|
|
720
756
|
const getter = headers.getSetCookie;
|
|
@@ -1844,6 +1880,10 @@ async function redditComments(input, token, id) {
|
|
|
1844
1880
|
try {
|
|
1845
1881
|
const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
|
|
1846
1882
|
const response = await input.net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=${Math.min(limit, 100)}&depth=1&sort=top`, { headers }, 1);
|
|
1883
|
+
if (response.status === 403) {
|
|
1884
|
+
await response.body?.cancel();
|
|
1885
|
+
return await graphqlComments(input.net, headers, id, limit);
|
|
1886
|
+
}
|
|
1847
1887
|
if (!response.ok)
|
|
1848
1888
|
throw new Error("Reddit comments failed");
|
|
1849
1889
|
const payload = await response.json();
|
|
@@ -1886,6 +1926,10 @@ async function redditComments(input, token, id) {
|
|
|
1886
1926
|
const url = new URL("https://oauth.reddit.com/api/morechildren");
|
|
1887
1927
|
url.search = new URLSearchParams({ api_type: "json", raw_json: "1", link_id: `t3_${id}`, children: children.join(","), sort: "top", depth: "1", limit_children: "true" }).toString();
|
|
1888
1928
|
const more = await input.net(url.href, { headers }, 1);
|
|
1929
|
+
if (more.status === 403) {
|
|
1930
|
+
await more.body?.cancel();
|
|
1931
|
+
return await graphqlComments(input.net, headers, id, limit);
|
|
1932
|
+
}
|
|
1889
1933
|
if (!more.ok)
|
|
1890
1934
|
throw new Error("Reddit more comments failed");
|
|
1891
1935
|
const result = await more.json();
|
|
@@ -1900,6 +1944,79 @@ async function redditComments(input, token, id) {
|
|
|
1900
1944
|
return [];
|
|
1901
1945
|
}
|
|
1902
1946
|
}
|
|
1947
|
+
var postCommentsHash = "314f29c2494fd78c7844ef2c6b09acb4bef5b37ab50b4ddc01691d6d5c8433f8";
|
|
1948
|
+
async function graphqlComments(net, headers, id, limit) {
|
|
1949
|
+
const comments = [];
|
|
1950
|
+
const seen = new Set;
|
|
1951
|
+
const cursors = new Set;
|
|
1952
|
+
let after;
|
|
1953
|
+
do {
|
|
1954
|
+
const response = await net("https://gql-fed.reddit.com/", {
|
|
1955
|
+
method: "POST",
|
|
1956
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
1957
|
+
body: JSON.stringify({
|
|
1958
|
+
operationName: "PostComments",
|
|
1959
|
+
variables: {
|
|
1960
|
+
id: `t3_${id}`,
|
|
1961
|
+
sortType: "TOP",
|
|
1962
|
+
count: Math.min(200, limit - comments.length),
|
|
1963
|
+
maxDepth: 1,
|
|
1964
|
+
includeConsistencyToken: false,
|
|
1965
|
+
includePremiumAvatarTreatment: false,
|
|
1966
|
+
...after && { after }
|
|
1967
|
+
},
|
|
1968
|
+
extensions: { persistedQuery: { version: 1, sha256Hash: postCommentsHash } }
|
|
1969
|
+
})
|
|
1970
|
+
}, 1);
|
|
1971
|
+
if (!response.ok)
|
|
1972
|
+
throw new Error(`Reddit GraphQL comments failed: ${response.status}`);
|
|
1973
|
+
const payload = await response.json();
|
|
1974
|
+
const data = object(payload) && object(payload.data) ? payload.data : null;
|
|
1975
|
+
const post = data && object(data.postInfoById) ? data.postInfoById : null;
|
|
1976
|
+
const forest = post && object(post.commentForest) ? post.commentForest : null;
|
|
1977
|
+
if (!forest || !Array.isArray(forest.trees))
|
|
1978
|
+
throw new Error("Invalid Reddit GraphQL comments");
|
|
1979
|
+
after = undefined;
|
|
1980
|
+
for (const tree of forest.trees) {
|
|
1981
|
+
if (!object(tree) || tree.depth !== 0 || tree.parentId != null && tree.parentId !== `t3_${id}`)
|
|
1982
|
+
continue;
|
|
1983
|
+
if (object(tree.more)) {
|
|
1984
|
+
after = string(tree.more.cursor) ?? undefined;
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
if (!object(tree.node) || comments.length >= limit)
|
|
1988
|
+
continue;
|
|
1989
|
+
const node = tree.node;
|
|
1990
|
+
const fullname = string(node.id);
|
|
1991
|
+
const content = object(node.content) ? node.content : null;
|
|
1992
|
+
const body = content && string(content.markdown);
|
|
1993
|
+
if (!fullname || !/^t1_[a-z0-9]+$/i.test(fullname) || seen.has(fullname) || !body || body === "[deleted]" || body === "[removed]" || node.__typename === "DeletedComment" || node.isRemoved === true || node.isAdminTakedown === true)
|
|
1994
|
+
continue;
|
|
1995
|
+
seen.add(fullname);
|
|
1996
|
+
const metadata = {};
|
|
1997
|
+
const media = Array.isArray(content.richtextMedia) ? content.richtextMedia.filter(object) : [];
|
|
1998
|
+
for (const asset of media) {
|
|
1999
|
+
const mediaId2 = string(asset.id);
|
|
2000
|
+
const url = string(asset.url);
|
|
2001
|
+
if (!mediaId2 || !url || asset.status && asset.status !== "VALID")
|
|
2002
|
+
continue;
|
|
2003
|
+
const mime = string(asset.mimetype);
|
|
2004
|
+
metadata[mediaId2] = { m: mime, s: mime === "video/mp4" ? { mp4: url } : { u: url } };
|
|
2005
|
+
}
|
|
2006
|
+
const createdAt = string(node.createdAt)?.replace(/(\.\d{3})\d+/, "$1").replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
|
2007
|
+
comments.push(redditComment({
|
|
2008
|
+
author: object(node.authorInfo) ? node.authorInfo.name : undefined,
|
|
2009
|
+
created_utc: createdAt ? Math.floor(Date.parse(createdAt) / 1000) : undefined,
|
|
2010
|
+
ups: node.score,
|
|
2011
|
+
media_metadata: metadata
|
|
2012
|
+
}, fullname.slice(3), id, body));
|
|
2013
|
+
}
|
|
2014
|
+
if (!after || cursors.has(after))
|
|
2015
|
+
break;
|
|
2016
|
+
cursors.add(after);
|
|
2017
|
+
} while (comments.length < limit);
|
|
2018
|
+
return comments;
|
|
2019
|
+
}
|
|
1903
2020
|
function redditComment(data, id, postId2, body) {
|
|
1904
2021
|
const metadata = object(data.media_metadata) ? data.media_metadata : {};
|
|
1905
2022
|
const mediaIds = Object.keys(metadata).filter((key) => object(metadata[key])).sort((left, right) => {
|
|
@@ -3913,6 +4030,10 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3913
4030
|
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
3914
4031
|
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
3915
4032
|
}
|
|
4033
|
+
const clip = items.at(-1)?.clip;
|
|
4034
|
+
if (clip && (!Number.isFinite(clip.start) || clip.start < 0 || clip.duration !== undefined && (!Number.isFinite(clip.duration) || clip.duration <= 0))) {
|
|
4035
|
+
throw new PostfetchError(400, "Audio clip must have a non-negative finite start and a positive finite duration");
|
|
4036
|
+
}
|
|
3916
4037
|
const width = options.width ?? 720;
|
|
3917
4038
|
const height2 = options.height ?? 1280;
|
|
3918
4039
|
for (const [name, value] of [["width", width], ["height", height2]]) {
|
|
@@ -3935,6 +4056,30 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3935
4056
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
3936
4057
|
paths.push(path);
|
|
3937
4058
|
}
|
|
4059
|
+
if (clip) {
|
|
4060
|
+
const path = `${directory}/soundtrack.wav`;
|
|
4061
|
+
const trimmed = await io.run(options.ffmpegPath ?? "ffmpeg", [
|
|
4062
|
+
"-nostdin",
|
|
4063
|
+
"-hide_banner",
|
|
4064
|
+
"-loglevel",
|
|
4065
|
+
"error",
|
|
4066
|
+
"-y",
|
|
4067
|
+
"-ss",
|
|
4068
|
+
String(clip.start),
|
|
4069
|
+
"-i",
|
|
4070
|
+
paths[visuals.length],
|
|
4071
|
+
...clip.duration === undefined ? [] : ["-t", String(clip.duration)],
|
|
4072
|
+
"-map",
|
|
4073
|
+
"0:a:0",
|
|
4074
|
+
"-c:a",
|
|
4075
|
+
"pcm_s16le",
|
|
4076
|
+
path
|
|
4077
|
+
]);
|
|
4078
|
+
if (!trimmed.success) {
|
|
4079
|
+
throw new PostfetchError(500, `Audio slider soundtrack trimming failed: ${trimmed.stderr}`);
|
|
4080
|
+
}
|
|
4081
|
+
paths[visuals.length] = path;
|
|
4082
|
+
}
|
|
3938
4083
|
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
3939
4084
|
"-v",
|
|
3940
4085
|
"error",
|
package/dist/internal.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ export type MediaItem = {
|
|
|
14
14
|
headers: HeadersInit;
|
|
15
15
|
url: string;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Playback segment in seconds when this audio item is a slideshow soundtrack.
|
|
19
|
+
* Download helpers return the source file; buildAudioSliderVideo trims this
|
|
20
|
+
* segment before measuring or looping it. Omitted duration plays to the end.
|
|
21
|
+
*/
|
|
22
|
+
clip?: {
|
|
23
|
+
start: number;
|
|
24
|
+
duration?: number;
|
|
25
|
+
};
|
|
17
26
|
/** Suggested download filename. */
|
|
18
27
|
filename: string;
|
|
19
28
|
/** Headers required to fetch {@link MediaItem.url} from the CDN. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.6",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/audio-slider.ts
CHANGED
|
@@ -23,8 +23,9 @@ const fps = 30;
|
|
|
23
23
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
24
24
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
25
25
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
26
|
-
* audio is discarded. The final audio item
|
|
27
|
-
*
|
|
26
|
+
* audio is discarded. The final audio item's optional `clip` selects the attached
|
|
27
|
+
* segment; otherwise the whole track plays. When the minimum slideshow duration
|
|
28
|
+
* is longer, that segment/track loops and is trimmed to the result.
|
|
28
29
|
*
|
|
29
30
|
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
30
31
|
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
|
@@ -55,6 +56,11 @@ export async function buildAudioSliderVideo(
|
|
|
55
56
|
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
56
57
|
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
57
58
|
}
|
|
59
|
+
const clip = items.at(-1)?.clip;
|
|
60
|
+
if (clip && (!Number.isFinite(clip.start) || clip.start < 0 ||
|
|
61
|
+
(clip.duration !== undefined && (!Number.isFinite(clip.duration) || clip.duration <= 0)))) {
|
|
62
|
+
throw new PostfetchError(400, "Audio clip must have a non-negative finite start and a positive finite duration");
|
|
63
|
+
}
|
|
58
64
|
const width = options.width ?? 720;
|
|
59
65
|
const height = options.height ?? 1280;
|
|
60
66
|
for (const [name, value] of [["width", width], ["height", height]] as const) {
|
|
@@ -78,6 +84,19 @@ export async function buildAudioSliderVideo(
|
|
|
78
84
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
79
85
|
paths.push(path);
|
|
80
86
|
}
|
|
87
|
+
if (clip) {
|
|
88
|
+
const path = `${directory}/soundtrack.wav`;
|
|
89
|
+
const trimmed = await io.run(options.ffmpegPath ?? "ffmpeg", [
|
|
90
|
+
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
|
91
|
+
"-ss", String(clip.start), "-i", paths[visuals.length],
|
|
92
|
+
...(clip.duration === undefined ? [] : ["-t", String(clip.duration)]),
|
|
93
|
+
"-map", "0:a:0", "-c:a", "pcm_s16le", path,
|
|
94
|
+
]);
|
|
95
|
+
if (!trimmed.success) {
|
|
96
|
+
throw new PostfetchError(500, `Audio slider soundtrack trimming failed: ${trimmed.stderr}`);
|
|
97
|
+
}
|
|
98
|
+
paths[visuals.length] = path;
|
|
99
|
+
}
|
|
81
100
|
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
82
101
|
"-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
83
102
|
"stream=duration:format=duration", "-of", "json", paths[visuals.length],
|
package/src/instagram.ts
CHANGED
|
@@ -60,9 +60,11 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
60
60
|
const code = shortcode(input.url);
|
|
61
61
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
62
62
|
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
63
|
+
let mobile: Promise<Json | null> | undefined;
|
|
64
|
+
const getMobile = () => mobile ??= mobileMedia(input.net, code, input.preferredWidth, page.media);
|
|
63
65
|
const resolvers: Array<() => Promise<Json | null>> = [
|
|
64
66
|
async () => page.media,
|
|
65
|
-
|
|
67
|
+
getMobile,
|
|
66
68
|
() => embedMedia(input.net, code),
|
|
67
69
|
() => graphqlMedia(input.net, code),
|
|
68
70
|
];
|
|
@@ -81,6 +83,18 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
81
83
|
if (items.length === 0) {
|
|
82
84
|
throw new Error("Instagram media url not found");
|
|
83
85
|
}
|
|
86
|
+
// The logged-out page often omits music_metadata altogether. Ask the mobile
|
|
87
|
+
// endpoint once for that post's soundtrack, retaining the page's visuals and
|
|
88
|
+
// metadata even when the optional lookup is gated or fails. An explicit null
|
|
89
|
+
// means there is no soundtrack; a video's audio is already in its MP4.
|
|
90
|
+
if (soundtrackEligible(items) && !items.some((item) => item.kind === "audio") &&
|
|
91
|
+
media.music_metadata === undefined && !object(media.clips_metadata)) {
|
|
92
|
+
const richer = await getMobile().catch(() => null);
|
|
93
|
+
const samePost = richer && (string(richer.code) ? richer.code === code :
|
|
94
|
+
string(richer.pk) !== null && string(richer.pk) === string(media.pk));
|
|
95
|
+
const audio = samePost ? instagramAudio(richer, code) : null;
|
|
96
|
+
if (audio) items.push(audio);
|
|
97
|
+
}
|
|
84
98
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
85
99
|
}
|
|
86
100
|
|
|
@@ -261,8 +275,8 @@ function searchMedia(node: unknown, code: string): Json | null {
|
|
|
261
275
|
return null;
|
|
262
276
|
}
|
|
263
277
|
|
|
264
|
-
async function mobileMedia(net: Net, code: string, preferredWidth: number): Promise<Json | null> {
|
|
265
|
-
const id = await mediaId(net, code);
|
|
278
|
+
async function mobileMedia(net: Net, code: string, preferredWidth: number, hint?: Json | null): Promise<Json | null> {
|
|
279
|
+
const id = string(hint?.pk) ?? string(hint?.id) ?? await mediaId(net, code);
|
|
266
280
|
if (!id) {
|
|
267
281
|
return null;
|
|
268
282
|
}
|
|
@@ -429,15 +443,46 @@ function mediaItems(media: Json, code: string, preferredWidth: number): MediaIte
|
|
|
429
443
|
const node = object(edge) && object(edge.node) ? edge.node : null;
|
|
430
444
|
return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
|
|
431
445
|
});
|
|
432
|
-
if (oldItems.length > 0) {
|
|
433
|
-
return oldItems;
|
|
434
|
-
}
|
|
435
446
|
const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
|
|
436
447
|
const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
448
|
+
const items = oldItems.length > 0 ? oldItems : newItems.length > 0 ? newItems : instagramItem(media, code, null, preferredWidth);
|
|
449
|
+
const audio = soundtrackEligible(items) ? instagramAudio(media, code) : null;
|
|
450
|
+
return audio ? [...items, audio] : items;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function soundtrackEligible(items: MediaItem[]): boolean {
|
|
454
|
+
return items.length > 1 || items[0]?.kind === "image";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Normalize Instagram's field names to the same trailing audio item used by
|
|
458
|
+
// other resolvers. Consumers can build a slideshow without platform branches.
|
|
459
|
+
function instagramAudio(media: Json, code: string): MediaItem | null {
|
|
460
|
+
const metadata = object(media.music_metadata) ? media.music_metadata : object(media.clips_metadata) ? media.clips_metadata : null;
|
|
461
|
+
if (!metadata) return null;
|
|
462
|
+
const info = object(metadata.music_info) ? metadata.music_info : null;
|
|
463
|
+
const consumption = info && object(info.music_consumption_info) ? info.music_consumption_info : null;
|
|
464
|
+
if (consumption?.should_mute_audio === true) return null;
|
|
465
|
+
const asset = info && object(info.music_asset_info) ? info.music_asset_info : null;
|
|
466
|
+
const original = object(metadata.original_sound_info) ? metadata.original_sound_info : null;
|
|
467
|
+
const selected = [asset, original].find((value) => value &&
|
|
468
|
+
(string(value.progressive_download_url) ?? string(value.fast_start_progressive_download_url)));
|
|
469
|
+
const url = selected && (string(selected.progressive_download_url) ?? string(selected.fast_start_progressive_download_url));
|
|
470
|
+
if (!url || !/^https?:\/\//i.test(url)) return null;
|
|
471
|
+
const start = number(consumption?.audio_asset_start_time_in_ms);
|
|
472
|
+
const duration = number(consumption?.overlap_duration_in_ms);
|
|
473
|
+
const clip = (start !== null && start >= 0) || (duration !== null && duration > 0)
|
|
474
|
+
? { start: Math.max(0, start ?? 0) / 1000, ...(duration !== null && duration > 0 ? { duration: duration / 1000 } : {}) }
|
|
475
|
+
: undefined;
|
|
476
|
+
return {
|
|
477
|
+
filename: filename(`instagram_${code}_audio.m4a`),
|
|
478
|
+
headers: { "user-agent": browserUserAgent() },
|
|
479
|
+
id: code,
|
|
480
|
+
kind: "audio",
|
|
481
|
+
mime: "audio/mp4",
|
|
482
|
+
platform: "instagram",
|
|
483
|
+
url,
|
|
484
|
+
...(clip && { clip }),
|
|
485
|
+
};
|
|
441
486
|
}
|
|
442
487
|
|
|
443
488
|
function instagramCookies(headers: Headers, fallback: Record<string, string | null>): string {
|
package/src/internal.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type MediaItem = {
|
|
|
13
13
|
* the bundled remuxer; callers that read `url` directly get the silent video.
|
|
14
14
|
*/
|
|
15
15
|
audio?: { headers: HeadersInit; url: string };
|
|
16
|
+
/**
|
|
17
|
+
* Playback segment in seconds when this audio item is a slideshow soundtrack.
|
|
18
|
+
* Download helpers return the source file; buildAudioSliderVideo trims this
|
|
19
|
+
* segment before measuring or looping it. Omitted duration plays to the end.
|
|
20
|
+
*/
|
|
21
|
+
clip?: { start: number; duration?: number };
|
|
16
22
|
/** Suggested download filename. */
|
|
17
23
|
filename: string;
|
|
18
24
|
/** Headers required to fetch {@link MediaItem.url} from the CDN. */
|
package/src/reddit.ts
CHANGED
|
@@ -329,6 +329,10 @@ async function redditComments(input: ResolveContext, token: string, id: string):
|
|
|
329
329
|
try {
|
|
330
330
|
const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
|
|
331
331
|
const response = await input.net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=${Math.min(limit, 100)}&depth=1&sort=top`, { headers }, 1);
|
|
332
|
+
if (response.status === 403) {
|
|
333
|
+
await response.body?.cancel();
|
|
334
|
+
return await graphqlComments(input.net, headers, id, limit);
|
|
335
|
+
}
|
|
332
336
|
if (!response.ok) throw new Error("Reddit comments failed");
|
|
333
337
|
const payload: unknown = await response.json();
|
|
334
338
|
const listing = Array.isArray(payload) ? payload[1] : null;
|
|
@@ -364,6 +368,10 @@ async function redditComments(input: ResolveContext, token: string, id: string):
|
|
|
364
368
|
const url = new URL("https://oauth.reddit.com/api/morechildren");
|
|
365
369
|
url.search = new URLSearchParams({ api_type: "json", raw_json: "1", link_id: `t3_${id}`, children: children.join(","), sort: "top", depth: "1", limit_children: "true" }).toString();
|
|
366
370
|
const more = await input.net(url.href, { headers }, 1);
|
|
371
|
+
if (more.status === 403) {
|
|
372
|
+
await more.body?.cancel();
|
|
373
|
+
return await graphqlComments(input.net, headers, id, limit);
|
|
374
|
+
}
|
|
367
375
|
if (!more.ok) throw new Error("Reddit more comments failed");
|
|
368
376
|
const result: unknown = await more.json();
|
|
369
377
|
const json = object(result) && object(result.json) ? result.json : null;
|
|
@@ -377,6 +385,75 @@ async function redditComments(input: ResolveContext, token: string, id: string):
|
|
|
377
385
|
}
|
|
378
386
|
}
|
|
379
387
|
|
|
388
|
+
// Reddit blocks anonymous OAuth comment routes while its mobile GraphQL API
|
|
389
|
+
// still accepts the same token. This is the app's registered PostComments query;
|
|
390
|
+
// gql-fed rejects arbitrary query text. Keep the JSON API as the first choice.
|
|
391
|
+
const postCommentsHash = "314f29c2494fd78c7844ef2c6b09acb4bef5b37ab50b4ddc01691d6d5c8433f8";
|
|
392
|
+
|
|
393
|
+
async function graphqlComments(net: Net, headers: Record<string, string>, id: string, limit: number): Promise<PostComment[]> {
|
|
394
|
+
const comments: PostComment[] = [];
|
|
395
|
+
const seen = new Set<string>();
|
|
396
|
+
const cursors = new Set<string>();
|
|
397
|
+
let after: string | undefined;
|
|
398
|
+
do {
|
|
399
|
+
const response = await net("https://gql-fed.reddit.com/", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
operationName: "PostComments",
|
|
404
|
+
variables: {
|
|
405
|
+
id: `t3_${id}`, sortType: "TOP", count: Math.min(200, limit - comments.length), maxDepth: 1,
|
|
406
|
+
includeConsistencyToken: false, includePremiumAvatarTreatment: false,
|
|
407
|
+
...(after && { after }),
|
|
408
|
+
},
|
|
409
|
+
extensions: { persistedQuery: { version: 1, sha256Hash: postCommentsHash } },
|
|
410
|
+
}),
|
|
411
|
+
}, 1);
|
|
412
|
+
if (!response.ok) throw new Error(`Reddit GraphQL comments failed: ${response.status}`);
|
|
413
|
+
const payload: unknown = await response.json();
|
|
414
|
+
const data = object(payload) && object(payload.data) ? payload.data : null;
|
|
415
|
+
const post = data && object(data.postInfoById) ? data.postInfoById : null;
|
|
416
|
+
const forest = post && object(post.commentForest) ? post.commentForest : null;
|
|
417
|
+
if (!forest || !Array.isArray(forest.trees)) throw new Error("Invalid Reddit GraphQL comments");
|
|
418
|
+
after = undefined;
|
|
419
|
+
for (const tree of forest.trees) {
|
|
420
|
+
if (!object(tree) || tree.depth !== 0 || (tree.parentId != null && tree.parentId !== `t3_${id}`)) continue;
|
|
421
|
+
if (object(tree.more)) {
|
|
422
|
+
after = string(tree.more.cursor) ?? undefined;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (!object(tree.node) || comments.length >= limit) continue;
|
|
426
|
+
const node = tree.node;
|
|
427
|
+
const fullname = string(node.id);
|
|
428
|
+
const content = object(node.content) ? node.content : null;
|
|
429
|
+
const body = content && string(content.markdown);
|
|
430
|
+
if (!fullname || !/^t1_[a-z0-9]+$/i.test(fullname) || seen.has(fullname) || !body
|
|
431
|
+
|| body === "[deleted]" || body === "[removed]" || node.__typename === "DeletedComment" || node.isRemoved === true || node.isAdminTakedown === true) continue;
|
|
432
|
+
seen.add(fullname);
|
|
433
|
+
const metadata: Json = {};
|
|
434
|
+
const media = Array.isArray(content.richtextMedia) ? content.richtextMedia.filter(object) : [];
|
|
435
|
+
for (const asset of media) {
|
|
436
|
+
const mediaId = string(asset.id);
|
|
437
|
+
const url = string(asset.url);
|
|
438
|
+
if (!mediaId || !url || (asset.status && asset.status !== "VALID")) continue;
|
|
439
|
+
const mime = string(asset.mimetype);
|
|
440
|
+
metadata[mediaId] = { m: mime, s: mime === "video/mp4" ? { mp4: url } : { u: url } };
|
|
441
|
+
}
|
|
442
|
+
// The API uses microseconds and offsets like +0000; normalize for JS dates.
|
|
443
|
+
const createdAt = string(node.createdAt)?.replace(/(\.\d{3})\d+/, "$1").replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
|
444
|
+
comments.push(redditComment({
|
|
445
|
+
author: object(node.authorInfo) ? node.authorInfo.name : undefined,
|
|
446
|
+
created_utc: createdAt ? Math.floor(Date.parse(createdAt) / 1000) : undefined,
|
|
447
|
+
ups: node.score,
|
|
448
|
+
media_metadata: metadata,
|
|
449
|
+
}, fullname.slice(3), id, body));
|
|
450
|
+
}
|
|
451
|
+
if (!after || cursors.has(after)) break;
|
|
452
|
+
cursors.add(after);
|
|
453
|
+
} while (comments.length < limit);
|
|
454
|
+
return comments;
|
|
455
|
+
}
|
|
456
|
+
|
|
380
457
|
function redditComment(data: Json, id: string, postId: string, body: string): PostComment {
|
|
381
458
|
const metadata = object(data.media_metadata) ? data.media_metadata : {};
|
|
382
459
|
// Comment images/GIFs use the same rendition metadata as Reddit galleries.
|