@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 +23 -2
- package/package.json +1 -1
- package/src/facebook.ts +40 -3
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
|
|
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.
|
|
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
|
-
|
|
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) {
|