@postfetch/core 0.2.0
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/README.md +20 -0
- package/dist/download.d.ts +12 -0
- package/dist/facebook.d.ts +2 -0
- package/dist/fingerprint.d.ts +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1089 -0
- package/dist/instagram.d.ts +2 -0
- package/dist/internal.d.ts +34 -0
- package/dist/postfetch.d.ts +7 -0
- package/dist/tiktok.d.ts +2 -0
- package/dist/twitter.d.ts +2 -0
- package/dist/youtube.d.ts +3 -0
- package/dist/zip.d.ts +5 -0
- package/package.json +45 -0
- package/src/download.ts +74 -0
- package/src/facebook.ts +73 -0
- package/src/fingerprint.ts +75 -0
- package/src/index.ts +4 -0
- package/src/instagram.ts +380 -0
- package/src/internal.ts +113 -0
- package/src/postfetch.ts +55 -0
- package/src/tiktok.ts +187 -0
- package/src/twitter.ts +81 -0
- package/src/youtube.ts +194 -0
- package/src/zip.ts +121 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type Platform = "facebook" | "instagram" | "tiktok" | "twitter" | "youtube";
|
|
2
|
+
export type MediaKind = "audio" | "image" | "video";
|
|
3
|
+
export type MediaItem = {
|
|
4
|
+
filename: string;
|
|
5
|
+
headers: HeadersInit;
|
|
6
|
+
id: string;
|
|
7
|
+
kind: MediaKind;
|
|
8
|
+
mime: string;
|
|
9
|
+
platform: Platform;
|
|
10
|
+
url: string;
|
|
11
|
+
};
|
|
12
|
+
export type PostfetchResult = {
|
|
13
|
+
archiveFilename: string;
|
|
14
|
+
id: string;
|
|
15
|
+
items: MediaItem[];
|
|
16
|
+
platform: Platform;
|
|
17
|
+
};
|
|
18
|
+
export type Net = (url: string, init?: RequestInit, attempts?: number) => Promise<Response>;
|
|
19
|
+
export type ResolveContext = {
|
|
20
|
+
net: Net;
|
|
21
|
+
preferredWidth: number;
|
|
22
|
+
url: string;
|
|
23
|
+
};
|
|
24
|
+
export declare class PostfetchError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
constructor(status: number, message: string);
|
|
27
|
+
}
|
|
28
|
+
export type Json = Record<string, unknown>;
|
|
29
|
+
export declare function createNet(baseFetch?: typeof fetch): Net;
|
|
30
|
+
export declare function object(value: unknown): value is Json;
|
|
31
|
+
export declare function string(value: unknown): string | null;
|
|
32
|
+
export declare function number(value: unknown): number | null;
|
|
33
|
+
export declare function asUrl(value: string): URL;
|
|
34
|
+
export declare function filename(value: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Platform, type PostfetchResult } from "./internal";
|
|
2
|
+
export type PostfetchOptions = {
|
|
3
|
+
fetch?: typeof fetch;
|
|
4
|
+
preferredWidth?: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function postfetch(url: string, options?: PostfetchOptions): Promise<PostfetchResult>;
|
|
7
|
+
export declare function detect(url: string): Platform;
|
package/dist/tiktok.d.ts
ADDED
package/dist/zip.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@postfetch/core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Zero-dependency typed core that turns Facebook, Instagram, TikTok, X and YouTube post URLs into media files.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"bun": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/chelokot/postfetch.git",
|
|
25
|
+
"directory": "packages/core"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/chelokot/postfetch/tree/main/packages/core#readme",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"cobalt",
|
|
30
|
+
"downloader",
|
|
31
|
+
"facebook",
|
|
32
|
+
"instagram",
|
|
33
|
+
"media",
|
|
34
|
+
"tiktok",
|
|
35
|
+
"twitter",
|
|
36
|
+
"youtube",
|
|
37
|
+
"zero-dependency"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --format esm && tsc -p tsconfig.build.json",
|
|
41
|
+
"prepublishOnly": "bun run build",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/download.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createNet, PostfetchError, type MediaItem, type PostfetchResult } from "./internal";
|
|
2
|
+
import { zip } from "./zip";
|
|
3
|
+
|
|
4
|
+
export type DownloadOptions = {
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type Archive = {
|
|
9
|
+
bytes: Uint8Array;
|
|
10
|
+
filename: string;
|
|
11
|
+
mime: "application/zip";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function download(item: MediaItem, options: DownloadOptions = {}): Promise<Response> {
|
|
15
|
+
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
16
|
+
const response = await net(item.url, { headers: item.headers });
|
|
17
|
+
if (!response.ok || !response.body) {
|
|
18
|
+
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function archive(result: PostfetchResult, options: DownloadOptions = {}): Promise<Archive> {
|
|
24
|
+
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
25
|
+
const files = await Promise.all(
|
|
26
|
+
result.items.map(async (item) => {
|
|
27
|
+
const response = await net(item.url, { headers: item.headers });
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
return { data: new Uint8Array(await response.arrayBuffer()), name: item.filename };
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
return { bytes: zip(files), filename: result.archiveFilename, mime: "application/zip" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function toResponse(result: PostfetchResult, options: DownloadOptions = {}): Promise<Response> {
|
|
38
|
+
if (result.items.length === 1) {
|
|
39
|
+
return singleResponse(result.items[0], options);
|
|
40
|
+
}
|
|
41
|
+
return archiveResponse(result, options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function singleResponse(item: MediaItem, options: DownloadOptions): Promise<Response> {
|
|
45
|
+
const media = await download(item, options);
|
|
46
|
+
const headers = new Headers({
|
|
47
|
+
"content-disposition": `attachment; filename="${item.filename}"`,
|
|
48
|
+
"content-type": media.headers.get("content-type") ?? item.mime,
|
|
49
|
+
"x-media-count": "1",
|
|
50
|
+
"x-media-id": item.id,
|
|
51
|
+
"x-media-kind": item.kind,
|
|
52
|
+
"x-media-platform": item.platform,
|
|
53
|
+
});
|
|
54
|
+
const length = media.headers.get("content-length");
|
|
55
|
+
if (length) {
|
|
56
|
+
headers.set("content-length", length);
|
|
57
|
+
}
|
|
58
|
+
return new Response(media.body, { headers });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function archiveResponse(result: PostfetchResult, options: DownloadOptions): Promise<Response> {
|
|
62
|
+
const { bytes, filename } = await archive(result, options);
|
|
63
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
64
|
+
return new Response(body, {
|
|
65
|
+
headers: {
|
|
66
|
+
"content-disposition": `attachment; filename="${filename}"`,
|
|
67
|
+
"content-length": String(bytes.length),
|
|
68
|
+
"content-type": "application/zip",
|
|
69
|
+
"x-media-count": String(result.items.length),
|
|
70
|
+
"x-media-id": result.id,
|
|
71
|
+
"x-media-platform": result.platform,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/facebook.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { asUrl, filename, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
|
|
2
|
+
import { browserUserAgent } from "./fingerprint";
|
|
3
|
+
|
|
4
|
+
export async function resolveFacebook(input: ResolveContext): Promise<PostfetchResult> {
|
|
5
|
+
const canonical = await canonicalUrl(input.net, input.url);
|
|
6
|
+
const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
|
|
7
|
+
const url = await embedVideo(input.net, canonical);
|
|
8
|
+
if (!url) {
|
|
9
|
+
throw new Error("Facebook video not found");
|
|
10
|
+
}
|
|
11
|
+
const item: MediaItem = {
|
|
12
|
+
filename: filename(`facebook_${id}.mp4`),
|
|
13
|
+
headers: { "user-agent": browserUserAgent() },
|
|
14
|
+
id,
|
|
15
|
+
kind: "video",
|
|
16
|
+
mime: "video/mp4",
|
|
17
|
+
platform: "facebook",
|
|
18
|
+
url,
|
|
19
|
+
};
|
|
20
|
+
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], platform: "facebook" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function navigationHeaders(): Record<string, string> {
|
|
24
|
+
return {
|
|
25
|
+
"accept-language": "en-US,en;q=0.9",
|
|
26
|
+
"sec-fetch-mode": "navigate",
|
|
27
|
+
"user-agent": browserUserAgent(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Share and fb.watch links redirect to the canonical /reel/<id> or /<page>/videos/<id>
|
|
32
|
+
// URL, which the public embed player needs. We only read the resolved location, not the
|
|
33
|
+
// (login-walled) page body.
|
|
34
|
+
async function canonicalUrl(net: Net, input: string): Promise<string> {
|
|
35
|
+
const response = await net(input, { headers: navigationHeaders() });
|
|
36
|
+
const resolved = asUrl(response.url);
|
|
37
|
+
return `${resolved.origin}${resolved.pathname}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The logged-out watch page is fingerprint-walled, but the public embed player
|
|
41
|
+
// (plugins/video.php) still exposes the progressive hd_src/sd_src URLs.
|
|
42
|
+
async function embedVideo(net: Net, canonical: string): Promise<string | null> {
|
|
43
|
+
const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
|
|
44
|
+
const response = await net(embed, { headers: navigationHeaders() });
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const html = await response.text();
|
|
49
|
+
return source(html, "hd_src") ?? source(html, "sd_src");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function source(html: string, key: string): string | null {
|
|
53
|
+
const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
|
|
54
|
+
if (!match) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const decoded: Json | string | null = JSON.parse(match[1]);
|
|
58
|
+
return typeof decoded === "string" && decoded.length > 0 ? decoded : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function facebookId(input: string): string | null {
|
|
62
|
+
const url = asUrl(input);
|
|
63
|
+
const fromQuery = url.searchParams.get("v");
|
|
64
|
+
if (fromQuery && /^\d+$/.test(fromQuery)) {
|
|
65
|
+
return fromQuery;
|
|
66
|
+
}
|
|
67
|
+
const numeric = url.pathname.match(/\/(?:reel|videos?|watch)\/(\d+)/);
|
|
68
|
+
if (numeric) {
|
|
69
|
+
return numeric[1];
|
|
70
|
+
}
|
|
71
|
+
const token = url.pathname.match(/\/(?:share\/[a-z]\/|posts\/|videos\/)?([A-Za-z0-9]+)\/?$/);
|
|
72
|
+
return token ? token[1] : null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Every outbound request rotates a fresh, internally-consistent client fingerprint.
|
|
2
|
+
// A single hardcoded user-agent / header set is the fastest way to get the whole
|
|
3
|
+
// fleet blocked at once, so each component is drawn from a pool and the pieces are
|
|
4
|
+
// kept consistent with each other (a Chrome UA always carries a matching Chrome
|
|
5
|
+
// `sec-ch-ua` and the right platform token).
|
|
6
|
+
|
|
7
|
+
function pick<Value>(values: readonly Value[]): Value {
|
|
8
|
+
return values[Math.floor(Math.random() * values.length)] as Value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const chromeVersions = ["128", "129", "130", "131", "132", "133"] as const;
|
|
12
|
+
const firefoxVersions = ["140", "143", "146", "148"] as const;
|
|
13
|
+
|
|
14
|
+
const desktopPlatforms = [
|
|
15
|
+
{ chPlatform: "Windows", uaToken: "Windows NT 10.0; Win64; x64" },
|
|
16
|
+
{ chPlatform: "macOS", uaToken: "Macintosh; Intel Mac OS X 10_15_7" },
|
|
17
|
+
{ chPlatform: "Linux", uaToken: "X11; Linux x86_64" },
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const acceptLanguages = ["en-US,en;q=0.9", "en-GB,en;q=0.9", "en;q=0.9", "en-US,en;q=0.8"] as const;
|
|
21
|
+
|
|
22
|
+
const instagramAppUserAgents = [
|
|
23
|
+
"Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)",
|
|
24
|
+
"Instagram 301.1.0.33.110 Android (34/14; 420dpi; 1080x2340; samsung; SM-G991B; o1s; exynos2100; en_US; 521879118)",
|
|
25
|
+
"Instagram 309.0.0.40.113 Android (33/13; 440dpi; 1080x2280; OnePlus; HD1913; OnePlus7TPro; qcom; en_US; 537291984)",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type BrowserFingerprint = {
|
|
29
|
+
acceptLanguage: string;
|
|
30
|
+
secChUa: string;
|
|
31
|
+
secChUaPlatform: string;
|
|
32
|
+
userAgent: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function browserFingerprint(): BrowserFingerprint {
|
|
36
|
+
const version = pick(chromeVersions);
|
|
37
|
+
const platform = pick(desktopPlatforms);
|
|
38
|
+
return {
|
|
39
|
+
acceptLanguage: pick(acceptLanguages),
|
|
40
|
+
secChUa: `"Chromium";v="${version}", "Google Chrome";v="${version}", "Not_A Brand";v="24"`,
|
|
41
|
+
secChUaPlatform: `"${platform.chPlatform}"`,
|
|
42
|
+
userAgent: `Mozilla/5.0 (${platform.uaToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version}.0.0.0 Safari/537.36`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function navigationHeaders(): Record<string, string> {
|
|
47
|
+
const fingerprint = browserFingerprint();
|
|
48
|
+
return {
|
|
49
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
50
|
+
"accept-language": fingerprint.acceptLanguage,
|
|
51
|
+
"sec-ch-ua": fingerprint.secChUa,
|
|
52
|
+
"sec-ch-ua-mobile": "?0",
|
|
53
|
+
"sec-ch-ua-platform": fingerprint.secChUaPlatform,
|
|
54
|
+
"sec-fetch-dest": "document",
|
|
55
|
+
"sec-fetch-mode": "navigate",
|
|
56
|
+
"sec-fetch-site": "none",
|
|
57
|
+
"sec-fetch-user": "?1",
|
|
58
|
+
"upgrade-insecure-requests": "1",
|
|
59
|
+
"user-agent": fingerprint.userAgent,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function browserUserAgent(): string {
|
|
64
|
+
return browserFingerprint().userAgent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function firefoxUserAgent(): string {
|
|
68
|
+
const platform = pick(desktopPlatforms);
|
|
69
|
+
const version = pick(firefoxVersions);
|
|
70
|
+
return `Mozilla/5.0 (${platform.uaToken}; rv:${version}.0) Gecko/20100101 Firefox/${version}.0`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function instagramAppUserAgent(): string {
|
|
74
|
+
return pick(instagramAppUserAgents);
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
2
|
+
export { download, archive, toResponse, type Archive, type DownloadOptions } from "./download";
|
|
3
|
+
export { PostfetchError } from "./internal";
|
|
4
|
+
export type { MediaItem, MediaKind, Platform, PostfetchResult } from "./internal";
|