@postfetch/core 0.5.2 → 0.7.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 +8 -4
- package/dist/download.d.ts +20 -0
- package/dist/fingerprint.d.ts +5 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +500 -59
- package/dist/internal.d.ts +13 -2
- package/dist/linkedin.d.ts +3 -0
- package/dist/postfetch.d.ts +11 -3
- package/package.json +3 -2
- package/src/download.ts +32 -0
- package/src/facebook.ts +27 -13
- package/src/fingerprint.ts +21 -7
- package/src/index.ts +12 -4
- package/src/instagram.ts +54 -22
- package/src/internal.ts +12 -1
- package/src/linkedin.ts +243 -0
- package/src/postfetch.ts +88 -7
- package/src/tiktok.ts +121 -16
- package/src/twitter.ts +33 -3
package/dist/internal.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** A platform that {@link postfetch} can resolve. */
|
|
2
|
-
export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
|
|
2
|
+
export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
|
|
3
3
|
/** The kind of a {@link MediaItem}. */
|
|
4
4
|
export type MediaKind = "audio" | "image" | "video";
|
|
5
5
|
/** A single resolved media file: a direct URL plus everything needed to fetch and name it. */
|
|
@@ -99,6 +99,17 @@ export type TiktokExtra = {
|
|
|
99
99
|
export type TwitterExtra = {
|
|
100
100
|
/** BCP 47 language tag detected for the tweet. */
|
|
101
101
|
lang?: string;
|
|
102
|
+
/** The post quoted by this post, when X includes it in the resolved payload. */
|
|
103
|
+
quotedTweet?: TwitterQuotedTweet;
|
|
104
|
+
};
|
|
105
|
+
/** A quoted X post and its normalized metadata. */
|
|
106
|
+
export type TwitterQuotedTweet = {
|
|
107
|
+
/** Status id of the quoted post. */
|
|
108
|
+
id: string;
|
|
109
|
+
/** Metadata belonging to the quoted post rather than the outer post. */
|
|
110
|
+
metadata: PostMetadata & {
|
|
111
|
+
extra?: TwitterExtra;
|
|
112
|
+
};
|
|
102
113
|
};
|
|
103
114
|
/** Instagram-specific metadata. */
|
|
104
115
|
export type InstagramExtra = {
|
|
@@ -137,7 +148,7 @@ export type PinterestExtra = {
|
|
|
137
148
|
outboundLink?: string;
|
|
138
149
|
};
|
|
139
150
|
/** The result of resolving a post: its platform, id, media items and metadata. */
|
|
140
|
-
export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
|
|
151
|
+
export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
|
|
141
152
|
/** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
|
|
142
153
|
export type PlatformResult<P extends Platform, Extra> = {
|
|
143
154
|
/** Suggested filename when zipping every item. */
|
package/dist/postfetch.d.ts
CHANGED
|
@@ -5,16 +5,24 @@ export type PostfetchOptions = {
|
|
|
5
5
|
fetch?: typeof fetch;
|
|
6
6
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
7
7
|
preferredWidth?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Soft file-size cap in bytes. The normal rendition is resolved and probed
|
|
10
|
+
* with HEAD; when it exceeds the cap, a smaller available rendition is
|
|
11
|
+
* returned. If size discovery or the smaller rendition is unavailable, the
|
|
12
|
+
* normal result is returned unchanged.
|
|
13
|
+
*/
|
|
14
|
+
tryMaxBytes?: number;
|
|
8
15
|
};
|
|
9
16
|
/**
|
|
10
17
|
* Resolve a public social post URL to its media.
|
|
11
18
|
*
|
|
12
19
|
* Detects the platform from the URL host, then returns the post's media as typed
|
|
13
20
|
* {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
|
|
14
|
-
* performs no side effects beyond the lookup; use {@link download},
|
|
15
|
-
* or {@link toResponse} to turn the result
|
|
21
|
+
* performs no side effects beyond the lookup; use {@link download},
|
|
22
|
+
* {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
|
|
23
|
+
* into bytes, a `Blob` or a `Response`.
|
|
16
24
|
*
|
|
17
|
-
* @param url A post URL from
|
|
25
|
+
* @param url A post URL from a supported platform.
|
|
18
26
|
* @param options See {@link PostfetchOptions}.
|
|
19
27
|
* @returns The resolved post and its media items.
|
|
20
28
|
* @throws {PostfetchError} If the URL is empty or its host is unsupported.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-dependency typed core that turns Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"downloader",
|
|
31
31
|
"facebook",
|
|
32
32
|
"instagram",
|
|
33
|
+
"linkedin",
|
|
33
34
|
"media",
|
|
34
35
|
"pinterest",
|
|
35
36
|
"reddit",
|
package/src/download.ts
CHANGED
|
@@ -54,6 +54,38 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
|
|
|
54
54
|
return response;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Download an already-resolved media URL as a Blob.
|
|
59
|
+
*
|
|
60
|
+
* This is useful for consumers that need to upload media themselves instead of
|
|
61
|
+
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
62
|
+
* required CDN headers, pass them as the second argument.
|
|
63
|
+
*
|
|
64
|
+
* @param url A direct media URL returned by a resolver.
|
|
65
|
+
* @param headers Headers required by the resolved media URL.
|
|
66
|
+
* @param options Fetch options.
|
|
67
|
+
* @returns The downloaded media as a Blob.
|
|
68
|
+
*
|
|
69
|
+
* @example Upload a video with FormData
|
|
70
|
+
* ```ts
|
|
71
|
+
* const [media] = (await postfetch(sourceUrl)).items;
|
|
72
|
+
* const blob = await downloadBlob(media.url, media.headers);
|
|
73
|
+
* form.append("video", blob, media.filename);
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export async function downloadBlob(
|
|
77
|
+
url: string,
|
|
78
|
+
headers: HeadersInit = {},
|
|
79
|
+
options: DownloadOptions = {},
|
|
80
|
+
): Promise<Blob> {
|
|
81
|
+
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
82
|
+
const response = await net(url, { headers });
|
|
83
|
+
if (!response.ok || !response.body) {
|
|
84
|
+
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
return response.blob();
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* Download every item of a result and zip them in-process (store mode).
|
|
59
91
|
*
|
package/src/facebook.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { asUrl, count, filename, isoFromEpochSeconds, type PostMetadata, type ResolveContext, type Json, type Net, type PostfetchResult, type MediaItem } from "./internal";
|
|
1
|
+
import { asUrl, count, filename, isoFromEpochSeconds, PostfetchError, 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
|
-
const canonical = await canonicalUrl(input.net, input.url);
|
|
5
|
+
const canonical = await canonicalUrl(input.net, normalizedInputUrl(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
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;
|
|
9
|
+
const embed = await embedVideo(input.net, canonical, input.preferredWidth);
|
|
10
|
+
const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
|
|
11
11
|
const url = embed ?? watch?.url ?? null;
|
|
12
12
|
if (!url) {
|
|
13
|
-
throw new
|
|
13
|
+
throw new PostfetchError(404, "Facebook video not found", "notFound");
|
|
14
14
|
}
|
|
15
15
|
const item: MediaItem = {
|
|
16
16
|
filename: filename(`facebook_${id}.mp4`),
|
|
@@ -24,6 +24,16 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
|
|
|
24
24
|
return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Facebook share links are opaque shortlinks. Messaging apps sometimes append
|
|
28
|
+
// selected text to the copied URL as another path segment; Facebook returns 404
|
|
29
|
+
// for that form even though the share token is still intact. Strip everything
|
|
30
|
+
// after the token before following the redirect.
|
|
31
|
+
function normalizedInputUrl(input: string): string {
|
|
32
|
+
const url = asUrl(input);
|
|
33
|
+
const share = url.pathname.match(/^\/share\/([a-z])\/([A-Za-z0-9]+)/i);
|
|
34
|
+
return share ? `${url.origin}/share/${share[1]}/${share[2]}/` : input;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
function navigationHeaders(): Record<string, string> {
|
|
28
38
|
return {
|
|
29
39
|
"accept-language": "en-US,en;q=0.9",
|
|
@@ -43,19 +53,19 @@ async function canonicalUrl(net: Net, input: string): Promise<string> {
|
|
|
43
53
|
|
|
44
54
|
// The logged-out watch page is fingerprint-walled, but the public embed player
|
|
45
55
|
// (plugins/video.php) still exposes the progressive hd_src/sd_src URLs.
|
|
46
|
-
async function embedVideo(net: Net, canonical: string): Promise<string | null> {
|
|
56
|
+
async function embedVideo(net: Net, canonical: string, preferredWidth: number): Promise<string | null> {
|
|
47
57
|
const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
|
|
48
58
|
const response = await net(embed, { headers: navigationHeaders() });
|
|
49
59
|
if (!response.ok) {
|
|
50
60
|
return null;
|
|
51
61
|
}
|
|
52
62
|
const html = await response.text();
|
|
53
|
-
return source(html, "hd_src")
|
|
63
|
+
return preferredSource(preferredWidth, source(html, "hd_src"), source(html, "sd_src"));
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// The logged-out watch page (with &_rdr) embeds the progressive video URLs in
|
|
57
67
|
// 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> {
|
|
68
|
+
async function watchVideo(net: Net, id: string, preferredWidth: number): Promise<{ url: string; metadata: PostMetadata } | null> {
|
|
59
69
|
// Without the document Accept header Facebook serves a JS shell without the
|
|
60
70
|
// media JSON, so ask for HTML explicitly here.
|
|
61
71
|
const headers = { ...navigationHeaders(), accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
|
|
@@ -64,14 +74,18 @@ async function watchVideo(net: Net, id: string): Promise<{ url: string; metadata
|
|
|
64
74
|
return null;
|
|
65
75
|
}
|
|
66
76
|
const html = await response.text();
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
source(html, "playable_url") ??
|
|
71
|
-
source(html, "browser_native_sd_url");
|
|
77
|
+
const hd = source(html, "playable_url_quality_hd") ?? source(html, "browser_native_hd_url");
|
|
78
|
+
const sd = source(html, "playable_url") ?? source(html, "browser_native_sd_url");
|
|
79
|
+
const url = preferredSource(preferredWidth, hd, sd);
|
|
72
80
|
return url ? { url, metadata: watchMetadata(html) } : null;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
// Facebook labels its two progressive renditions as 720p HD and 360p SD. Pick
|
|
84
|
+
// the one closest to the requested width, preferring HD on the 540px midpoint.
|
|
85
|
+
function preferredSource(preferredWidth: number, hd: string | null, sd: string | null): string | null {
|
|
86
|
+
return preferredWidth >= 540 ? hd ?? sd : sd ?? hd;
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
// Only the fields that can be read unambiguously from the flat page: the
|
|
76
90
|
// caption (creation_story) and the reaction/comment/share counts appear in
|
|
77
91
|
// shapes shared with related videos and comment threads, so they are skipped
|
package/src/fingerprint.ts
CHANGED
|
@@ -32,6 +32,10 @@ export type BrowserFingerprint = {
|
|
|
32
32
|
userAgent: string;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export type NavigationHeaders = Record<string, string> & {
|
|
36
|
+
"user-agent": string;
|
|
37
|
+
};
|
|
38
|
+
|
|
35
39
|
export function browserFingerprint(): BrowserFingerprint {
|
|
36
40
|
const version = pick(chromeVersions);
|
|
37
41
|
const platform = pick(desktopPlatforms);
|
|
@@ -43,20 +47,26 @@ export function browserFingerprint(): BrowserFingerprint {
|
|
|
43
47
|
};
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
const fingerprint = browserFingerprint();
|
|
50
|
+
function baseNavigationHeaders(userAgent: string, acceptLanguage: string): NavigationHeaders {
|
|
48
51
|
return {
|
|
49
52
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
50
|
-
"accept-language":
|
|
51
|
-
"sec-ch-ua": fingerprint.secChUa,
|
|
52
|
-
"sec-ch-ua-mobile": "?0",
|
|
53
|
-
"sec-ch-ua-platform": fingerprint.secChUaPlatform,
|
|
53
|
+
"accept-language": acceptLanguage,
|
|
54
54
|
"sec-fetch-dest": "document",
|
|
55
55
|
"sec-fetch-mode": "navigate",
|
|
56
56
|
"sec-fetch-site": "none",
|
|
57
57
|
"sec-fetch-user": "?1",
|
|
58
58
|
"upgrade-insecure-requests": "1",
|
|
59
|
-
"user-agent":
|
|
59
|
+
"user-agent": userAgent,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function navigationHeaders(): NavigationHeaders {
|
|
64
|
+
const fingerprint = browserFingerprint();
|
|
65
|
+
return {
|
|
66
|
+
...baseNavigationHeaders(fingerprint.userAgent, fingerprint.acceptLanguage),
|
|
67
|
+
"sec-ch-ua": fingerprint.secChUa,
|
|
68
|
+
"sec-ch-ua-mobile": "?0",
|
|
69
|
+
"sec-ch-ua-platform": fingerprint.secChUaPlatform,
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -70,6 +80,10 @@ export function firefoxUserAgent(): string {
|
|
|
70
80
|
return `Mozilla/5.0 (${platform.uaToken}; rv:${version}.0) Gecko/20100101 Firefox/${version}.0`;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
export function firefoxNavigationHeaders(userAgent = firefoxUserAgent()): NavigationHeaders {
|
|
84
|
+
return baseNavigationHeaders(userAgent, pick(acceptLanguages));
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
export function instagramAppUserAgent(): string {
|
|
74
88
|
return pick(instagramAppUserAgents);
|
|
75
89
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turn a public social post URL into its media.
|
|
3
3
|
*
|
|
4
|
-
* {@link postfetch} resolves Instagram, TikTok, YouTube, Facebook and X (Twitter)
|
|
4
|
+
* {@link postfetch} resolves Instagram, LinkedIn, TikTok, YouTube, Facebook and X (Twitter)
|
|
5
5
|
* URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
|
|
6
6
|
* (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
|
|
7
7
|
* Cloudflare Workers and browsers alike. It only resolves the media; the
|
|
8
|
-
* {@link download}, {@link archive} and {@link toResponse}
|
|
9
|
-
* into bytes or a `Response`.
|
|
8
|
+
* {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
|
|
9
|
+
* helpers turn a result into bytes, a `Blob`, or a `Response`.
|
|
10
10
|
*
|
|
11
11
|
* @example Resolve a reel and save the video
|
|
12
12
|
* ```ts
|
|
@@ -20,7 +20,14 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
download,
|
|
25
|
+
downloadBlob,
|
|
26
|
+
archive,
|
|
27
|
+
toResponse,
|
|
28
|
+
type Archive,
|
|
29
|
+
type DownloadOptions,
|
|
30
|
+
} from "./download";
|
|
24
31
|
export { PostfetchError } from "./internal";
|
|
25
32
|
export type {
|
|
26
33
|
InstagramExtra,
|
|
@@ -37,5 +44,6 @@ export type {
|
|
|
37
44
|
SoundcloudExtra,
|
|
38
45
|
TiktokExtra,
|
|
39
46
|
TwitterExtra,
|
|
47
|
+
TwitterQuotedTweet,
|
|
40
48
|
YoutubeExtra,
|
|
41
49
|
} from "./internal";
|
package/src/instagram.ts
CHANGED
|
@@ -58,11 +58,21 @@ function embedHeaders(): Record<string, string> {
|
|
|
58
58
|
|
|
59
59
|
export async function resolveInstagram(input: ResolveContext): Promise<PostfetchResult> {
|
|
60
60
|
const code = shortcode(input.url);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
(
|
|
64
|
-
(
|
|
65
|
-
(
|
|
61
|
+
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
62
|
+
const resolvers: Array<() => Promise<Json | null>> = [
|
|
63
|
+
() => pageMedia(input.net, code, input.preferredWidth),
|
|
64
|
+
() => mobileMedia(input.net, code, input.preferredWidth),
|
|
65
|
+
() => embedMedia(input.net, code),
|
|
66
|
+
() => graphqlMedia(input.net, code),
|
|
67
|
+
];
|
|
68
|
+
let media: Json | null = null;
|
|
69
|
+
for (const resolve of resolvers) {
|
|
70
|
+
const candidate = await resolve();
|
|
71
|
+
if (candidate && (!videoRequired || mediaItems(candidate, code, input.preferredWidth).some((item) => item.kind === "video"))) {
|
|
72
|
+
media = candidate;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
66
76
|
if (!media) {
|
|
67
77
|
throw await instagramUnavailable(input.net, code);
|
|
68
78
|
}
|
|
@@ -301,23 +311,20 @@ async function graphqlMedia(net: Net, code: string): Promise<Json | null> {
|
|
|
301
311
|
}
|
|
302
312
|
const body = new URLSearchParams({
|
|
303
313
|
...params.body,
|
|
304
|
-
|
|
314
|
+
av: "0",
|
|
315
|
+
doc_id: "26130443479876713",
|
|
305
316
|
fb_api_caller_class: "RelayModern",
|
|
306
|
-
fb_api_req_friendly_name: "
|
|
317
|
+
fb_api_req_friendly_name: "PolarisPostRootQuery",
|
|
307
318
|
server_timestamps: "true",
|
|
308
|
-
variables: JSON.stringify({
|
|
309
|
-
fetch_tagged_user_count: null,
|
|
310
|
-
hoisted_comment_id: null,
|
|
311
|
-
hoisted_reply_id: null,
|
|
312
|
-
shortcode: code,
|
|
313
|
-
}),
|
|
319
|
+
variables: JSON.stringify({ shortcode: code }),
|
|
314
320
|
});
|
|
315
321
|
const response = await net("https://www.instagram.com/graphql/query", {
|
|
316
322
|
body,
|
|
317
323
|
headers: {
|
|
318
324
|
...embedHeaders(),
|
|
319
325
|
...params.headers,
|
|
320
|
-
"X-FB-Friendly-Name": "
|
|
326
|
+
"X-FB-Friendly-Name": "PolarisPostRootQuery",
|
|
327
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
321
328
|
"content-type": "application/x-www-form-urlencoded",
|
|
322
329
|
},
|
|
323
330
|
method: "POST",
|
|
@@ -344,14 +351,14 @@ async function graphqlParams(net: Net, code: string): Promise<{ body: Record<str
|
|
|
344
351
|
const push = entryObject("InstagramWebPushInfo", html);
|
|
345
352
|
const lsd = entryObject("LSD", html)?.token ?? randomToken();
|
|
346
353
|
const csrf = entryObject("InstagramSecurityConfig", html)?.csrf_token;
|
|
347
|
-
const cookie =
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
polaris?.
|
|
351
|
-
"
|
|
352
|
-
|
|
353
|
-
"
|
|
354
|
-
|
|
354
|
+
const cookie = instagramCookies(response.headers, {
|
|
355
|
+
csrftoken: string(csrf),
|
|
356
|
+
dpr: "2",
|
|
357
|
+
ig_did: string(polaris?.device_id),
|
|
358
|
+
ig_nrcb: "1",
|
|
359
|
+
mid: string(polaris?.machine_id),
|
|
360
|
+
wd: "1280x720",
|
|
361
|
+
});
|
|
355
362
|
return {
|
|
356
363
|
headers: {
|
|
357
364
|
"X-CSRFToken": string(csrf) ?? "",
|
|
@@ -386,6 +393,12 @@ async function graphqlParams(net: Net, code: string): Promise<{ body: Record<str
|
|
|
386
393
|
}
|
|
387
394
|
|
|
388
395
|
function gqlShortcodeMedia(data: Json): Json | null {
|
|
396
|
+
const webInfo = object(data.xdt_api__v1__media__shortcode__web_info) ? data.xdt_api__v1__media__shortcode__web_info : null;
|
|
397
|
+
const webItems = webInfo && Array.isArray(webInfo.items) ? webInfo.items : [];
|
|
398
|
+
const webMedia = webItems[0];
|
|
399
|
+
if (object(webMedia)) {
|
|
400
|
+
return webMedia;
|
|
401
|
+
}
|
|
389
402
|
const media = data.gql_data && object(data.gql_data)
|
|
390
403
|
? data.gql_data.shortcode_media ?? data.gql_data.xdt_shortcode_media
|
|
391
404
|
: data.shortcode_media ?? data.xdt_shortcode_media;
|
|
@@ -411,6 +424,25 @@ function mediaItems(media: Json, code: string, preferredWidth: number): MediaIte
|
|
|
411
424
|
return instagramItem(media, code, null, preferredWidth);
|
|
412
425
|
}
|
|
413
426
|
|
|
427
|
+
function instagramCookies(headers: Headers, fallback: Record<string, string | null>): string {
|
|
428
|
+
const getter = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
|
429
|
+
const raw = typeof getter === "function" ? getter.call(headers) : headers.get("set-cookie")?.split(/,(?=[^;]+?=)/) ?? [];
|
|
430
|
+
const cookies = new Map<string, string>();
|
|
431
|
+
for (const value of raw) {
|
|
432
|
+
const pair = value.split(";", 1)[0]?.trim();
|
|
433
|
+
const separator = pair?.indexOf("=") ?? -1;
|
|
434
|
+
if (pair && separator > 0) {
|
|
435
|
+
cookies.set(pair.slice(0, separator), pair.slice(separator + 1));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const [name, value] of Object.entries(fallback)) {
|
|
439
|
+
if (value && !cookies.has(name)) {
|
|
440
|
+
cookies.set(name, value);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
444
|
+
}
|
|
445
|
+
|
|
414
446
|
function instagramItem(media: Json, code: string, index: number | null, preferredWidth: number): MediaItem[] {
|
|
415
447
|
const video = selectVersion(media.video_versions, preferredWidth) ?? string(media.video_url);
|
|
416
448
|
const suffix = index === null ? "" : `_${index}`;
|
package/src/internal.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** A platform that {@link postfetch} can resolve. */
|
|
2
|
-
export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
|
|
2
|
+
export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
|
|
3
3
|
|
|
4
4
|
/** The kind of a {@link MediaItem}. */
|
|
5
5
|
export type MediaKind = "audio" | "image" | "video";
|
|
@@ -103,6 +103,16 @@ export type TiktokExtra = {
|
|
|
103
103
|
export type TwitterExtra = {
|
|
104
104
|
/** BCP 47 language tag detected for the tweet. */
|
|
105
105
|
lang?: string;
|
|
106
|
+
/** The post quoted by this post, when X includes it in the resolved payload. */
|
|
107
|
+
quotedTweet?: TwitterQuotedTweet;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** A quoted X post and its normalized metadata. */
|
|
111
|
+
export type TwitterQuotedTweet = {
|
|
112
|
+
/** Status id of the quoted post. */
|
|
113
|
+
id: string;
|
|
114
|
+
/** Metadata belonging to the quoted post rather than the outer post. */
|
|
115
|
+
metadata: PostMetadata & { extra?: TwitterExtra };
|
|
106
116
|
};
|
|
107
117
|
|
|
108
118
|
/** Instagram-specific metadata. */
|
|
@@ -149,6 +159,7 @@ export type PinterestExtra = {
|
|
|
149
159
|
export type PostfetchResult =
|
|
150
160
|
| PlatformResult<"facebook", never>
|
|
151
161
|
| PlatformResult<"instagram", InstagramExtra>
|
|
162
|
+
| PlatformResult<"linkedin", never>
|
|
152
163
|
| PlatformResult<"pinterest", PinterestExtra>
|
|
153
164
|
| PlatformResult<"reddit", RedditExtra>
|
|
154
165
|
| PlatformResult<"soundcloud", SoundcloudExtra>
|