@postfetch/core 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -156,7 +156,9 @@ function instagramAppUserAgent() {
156
156
  async function resolveFacebook(input) {
157
157
  const canonical = await canonicalUrl(input.net, input.url);
158
158
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
159
- const url = await embedVideo(input.net, canonical);
159
+ const embed = await embedVideo(input.net, canonical);
160
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id) : null;
161
+ const url = embed ?? watch?.url ?? null;
160
162
  if (!url) {
161
163
  throw new Error("Facebook video not found");
162
164
  }
@@ -169,7 +171,7 @@ async function resolveFacebook(input) {
169
171
  platform: "facebook",
170
172
  url
171
173
  };
172
- return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], platform: "facebook" };
174
+ return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
173
175
  }
174
176
  function navigationHeaders2() {
175
177
  return {
@@ -192,6 +194,25 @@ async function embedVideo(net, canonical) {
192
194
  const html = await response.text();
193
195
  return source(html, "hd_src") ?? source(html, "sd_src");
194
196
  }
197
+ async function watchVideo(net, id) {
198
+ const headers = { ...navigationHeaders2(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
199
+ const response = await net(`https://www.facebook.com/watch/?v=${id}&_rdr`, { headers });
200
+ if (!response.ok) {
201
+ return null;
202
+ }
203
+ const html = await response.text();
204
+ const url = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url") ?? source(html, "playable_url") ?? source(html, "browser_native_sd_url");
205
+ return url ? { url, metadata: watchMetadata(html) } : null;
206
+ }
207
+ function watchMetadata(html) {
208
+ const owner = html.match(/"name":("(?:\\.|[^"\\])*"),"__isVideoOwner"/);
209
+ const name = owner ? JSON.parse(owner[1]) : undefined;
210
+ return {
211
+ author: name ? { name } : undefined,
212
+ createdAt: isoFromEpochSeconds(html.match(/"publish_time":(\d+)/)?.[1]),
213
+ viewCount: count(html.match(/"video_view_count":(\d+)/)?.[1])
214
+ };
215
+ }
195
216
  function source(html, key) {
196
217
  const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
197
218
  if (!match) {
@@ -1467,7 +1488,7 @@ async function followShortlink(net, userAgent, input) {
1467
1488
  return href ? new URL(href.split("?")[0]).href : input;
1468
1489
  }
1469
1490
  function videoId(input) {
1470
- return asUrl(input).pathname.match(/video\/(\d+)/)?.[1] ?? null;
1491
+ return asUrl(input).pathname.match(/(?:video|photo)\/(\d+)/)?.[1] ?? null;
1471
1492
  }
1472
1493
  function itemStruct(html) {
1473
1494
  if (html.includes("SlardarWAF") || html.includes("_wafchallengeid")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Zero-dependency typed core that turns Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/facebook.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { asUrl, filename, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
1
+ import { asUrl, count, filename, isoFromEpochSeconds, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
2
2
  import { browserUserAgent } from "./fingerprint";
3
3
 
4
4
  export async function resolveFacebook(input: ResolveContext): Promise<PostfetchResult> {
5
5
  const canonical = await canonicalUrl(input.net, input.url);
6
6
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
7
- const url = await embedVideo(input.net, canonical);
7
+ // The embed player only exposes /share/v/ videos; reels (and anything it
8
+ // misses) come from the watch page, which the &_rdr flag serves logged-out.
9
+ const embed = await embedVideo(input.net, canonical);
10
+ const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id) : null;
11
+ const url = embed ?? watch?.url ?? null;
8
12
  if (!url) {
9
13
  throw new Error("Facebook video not found");
10
14
  }
@@ -17,7 +21,7 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
17
21
  platform: "facebook",
18
22
  url,
19
23
  };
20
- return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], platform: "facebook" };
24
+ return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
21
25
  }
22
26
 
23
27
  function navigationHeaders(): Record<string, string> {
@@ -49,6 +53,39 @@ async function embedVideo(net: Net, canonical: string): Promise<string | null> {
49
53
  return source(html, "hd_src") ?? source(html, "sd_src");
50
54
  }
51
55
 
56
+ // The logged-out watch page (with &_rdr) embeds the progressive video URLs in
57
+ // its ScheduledServerJS blocks; these are the same fields yt-dlp reads.
58
+ async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata: PostMetadata } | null> {
59
+ // Without the document Accept header Facebook serves a JS shell without the
60
+ // media JSON, so ask for HTML explicitly here.
61
+ const headers = { ...navigationHeaders(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
62
+ const response = await net(`https://www.facebook.com/watch/?v=${id}&_rdr`, { headers });
63
+ if (!response.ok) {
64
+ return null;
65
+ }
66
+ const html = await response.text();
67
+ const url =
68
+ source(html, "playable_url_quality_hd") ??
69
+ source(html, "browser_native_hd_url") ??
70
+ source(html, "playable_url") ??
71
+ source(html, "browser_native_sd_url");
72
+ return url ? { url, metadata: watchMetadata(html) } : null;
73
+ }
74
+
75
+ // Only the fields that can be read unambiguously from the flat page: the
76
+ // caption (creation_story) and the reaction/comment/share counts appear in
77
+ // shapes shared with related videos and comment threads, so they are skipped
78
+ // rather than risk attributing the wrong value.
79
+ function watchMetadata(html: string): PostMetadata {
80
+ const owner = html.match(/"name":("(?:\\.|[^"\\])*"),"__isVideoOwner"/);
81
+ const name = owner ? (JSON.parse(owner[1]) as string) : undefined;
82
+ return {
83
+ author: name ? { name } : undefined,
84
+ createdAt: isoFromEpochSeconds(html.match(/"publish_time":(\d+)/)?.[1]),
85
+ viewCount: count(html.match(/"video_view_count":(\d+)/)?.[1]),
86
+ };
87
+ }
88
+
52
89
  function source(html: string, key: string): string | null {
53
90
  const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
54
91
  if (!match) {
package/src/tiktok.ts CHANGED
@@ -110,7 +110,8 @@ async function followShortlink(net: Net, userAgent: string, input: string): Prom
110
110
  }
111
111
 
112
112
  function videoId(input: string): string | null {
113
- return asUrl(input).pathname.match(/video\/(\d+)/)?.[1] ?? null;
113
+ // Photo (slideshow) posts share the video id namespace under /photo/<id>.
114
+ return asUrl(input).pathname.match(/(?:video|photo)\/(\d+)/)?.[1] ?? null;
114
115
  }
115
116
 
116
117
  function itemStruct(html: string): Json {