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