@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.
@@ -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 plays in full; when the minimum
22
- * slideshow duration is longer, it loops and is trimmed to the result.
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
- () => mobileMedia(input.net, code, input.preferredWidth),
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
- if (newItems.length > 0) {
715
- return newItems;
716
- }
717
- return instagramItem(media, code, null, preferredWidth);
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",
@@ -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.4",
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",
@@ -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 plays in full; when the minimum
27
- * slideshow duration is longer, it loops and is trimmed to the result.
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
- () => mobileMedia(input.net, code, input.preferredWidth),
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
- if (newItems.length > 0) {
438
- return newItems;
439
- }
440
- return instagramItem(media, code, null, preferredWidth);
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.