@postfetch/core 0.5.1 → 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.5.1",
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) {