@postfetch/core 0.5.1 → 0.6.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 +1 -1
- package/dist/fingerprint.d.ts +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +400 -45
- package/dist/internal.d.ts +2 -2
- package/dist/linkedin.d.ts +3 -0
- package/dist/postfetch.d.ts +1 -1
- package/package.json +3 -2
- package/src/facebook.ts +40 -3
- package/src/fingerprint.ts +21 -7
- package/src/index.ts +1 -1
- package/src/instagram.ts +54 -22
- package/src/internal.ts +2 -1
- package/src/linkedin.ts +243 -0
- package/src/postfetch.ts +8 -2
- package/src/tiktok.ts +121 -16
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.6.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/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/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,7 +1,7 @@
|
|
|
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
|
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";
|
|
@@ -149,6 +149,7 @@ export type PinterestExtra = {
|
|
|
149
149
|
export type PostfetchResult =
|
|
150
150
|
| PlatformResult<"facebook", never>
|
|
151
151
|
| PlatformResult<"instagram", InstagramExtra>
|
|
152
|
+
| PlatformResult<"linkedin", never>
|
|
152
153
|
| PlatformResult<"pinterest", PinterestExtra>
|
|
153
154
|
| PlatformResult<"reddit", RedditExtra>
|
|
154
155
|
| PlatformResult<"soundcloud", SoundcloudExtra>
|
package/src/linkedin.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
count,
|
|
3
|
+
filename,
|
|
4
|
+
isoFromDateString,
|
|
5
|
+
object,
|
|
6
|
+
PostfetchError,
|
|
7
|
+
string,
|
|
8
|
+
type Json,
|
|
9
|
+
type MediaItem,
|
|
10
|
+
type PostMetadata,
|
|
11
|
+
type PostfetchResult,
|
|
12
|
+
type ResolveContext,
|
|
13
|
+
} from "./internal";
|
|
14
|
+
import { browserUserAgent, navigationHeaders } from "./fingerprint";
|
|
15
|
+
|
|
16
|
+
type LinkedinPostKind = "activity" | "share" | "ugcPost";
|
|
17
|
+
|
|
18
|
+
type LinkedinPostReference = {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: LinkedinPostKind;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function resolveLinkedin(input: ResolveContext): Promise<PostfetchResult> {
|
|
24
|
+
const reference = postReference(input.url);
|
|
25
|
+
const pageUrl = `https://www.linkedin.com/feed/update/urn:li:${reference.kind}:${reference.id}`;
|
|
26
|
+
const response = await input.net(pageUrl, { headers: navigationHeaders() });
|
|
27
|
+
if (response.status === 404) {
|
|
28
|
+
throw new PostfetchError(404, "LinkedIn post not found", "notFound");
|
|
29
|
+
}
|
|
30
|
+
if (response.status === 429 || response.status === 999) {
|
|
31
|
+
throw new PostfetchError(429, "LinkedIn rate limit reached", "rateLimited");
|
|
32
|
+
}
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new PostfetchError(response.status, "LinkedIn post unavailable", "unavailable");
|
|
35
|
+
}
|
|
36
|
+
if (response.url.includes("/signup/") || response.url.includes("/login")) {
|
|
37
|
+
throw new PostfetchError(401, "LinkedIn login required", "loginRequired");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const html = await response.text();
|
|
41
|
+
const post = linkedinPost(html);
|
|
42
|
+
if (!post) {
|
|
43
|
+
throw new PostfetchError(404, "LinkedIn post unavailable", "unavailable");
|
|
44
|
+
}
|
|
45
|
+
const canonical = string(post["@id"]);
|
|
46
|
+
const canonicalReference = canonical ? postReferenceOrNull(canonical) : null;
|
|
47
|
+
const id = canonicalReference?.id ?? reference.id;
|
|
48
|
+
const items = linkedinItems(post, html, id, pageUrl);
|
|
49
|
+
if (items.length === 0) {
|
|
50
|
+
throw new Error("LinkedIn media url not found");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
archiveFilename: filename(`linkedin_${id}.zip`),
|
|
54
|
+
id,
|
|
55
|
+
items,
|
|
56
|
+
metadata: linkedinMetadata(post),
|
|
57
|
+
platform: "linkedin",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function linkedinMetadata(post: Json): PostMetadata {
|
|
62
|
+
const author = linkedinAuthor(post);
|
|
63
|
+
return {
|
|
64
|
+
title: string(post.text) ?? string(post.name) ?? string(post.headline) ?? undefined,
|
|
65
|
+
text: string(post.description) ?? string(post.articleBody) ?? string(post.text) ?? undefined,
|
|
66
|
+
author: author
|
|
67
|
+
? {
|
|
68
|
+
handle: linkedinHandle(string(author.url)) ?? undefined,
|
|
69
|
+
name: string(author.name) ?? undefined,
|
|
70
|
+
}
|
|
71
|
+
: undefined,
|
|
72
|
+
createdAt: isoFromDateString(post.datePublished ?? post.uploadDate),
|
|
73
|
+
likeCount: interactionCount(post.interactionStatistic, "LikeAction"),
|
|
74
|
+
commentCount: count(post.commentCount) ?? interactionCount(post.interactionStatistic, "CommentAction"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function postReference(input: string): LinkedinPostReference {
|
|
79
|
+
const reference = postReferenceOrNull(input);
|
|
80
|
+
if (!reference) {
|
|
81
|
+
throw new PostfetchError(400, "LinkedIn post id not found");
|
|
82
|
+
}
|
|
83
|
+
return reference;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function postReferenceOrNull(input: string): LinkedinPostReference | null {
|
|
87
|
+
let pathname: string;
|
|
88
|
+
try {
|
|
89
|
+
pathname = new URL(input).pathname.replace(/%3A/gi, ":");
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const urn = pathname.match(/\/(?:embed\/)?feed\/update\/urn:li:(activity|share|ugcpost):(\d+)\/?$/i);
|
|
94
|
+
const post = pathname.match(/\/posts\/[^/]*-(activity|share|ugcpost)-(\d+)(?:-[^/]*)?\/?$/i);
|
|
95
|
+
const match = urn ?? post;
|
|
96
|
+
if (!match) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
switch (match[1].toLowerCase()) {
|
|
100
|
+
case "activity":
|
|
101
|
+
return { id: match[2], kind: "activity" };
|
|
102
|
+
case "share":
|
|
103
|
+
return { id: match[2], kind: "share" };
|
|
104
|
+
case "ugcpost":
|
|
105
|
+
return { id: match[2], kind: "ugcPost" };
|
|
106
|
+
default:
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function linkedinAuthor(post: Json): Json | null {
|
|
112
|
+
if (object(post.creator)) {
|
|
113
|
+
return post.creator;
|
|
114
|
+
}
|
|
115
|
+
if (object(post.author)) {
|
|
116
|
+
return post.author;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function linkedinPost(html: string): Json | null {
|
|
122
|
+
for (const match of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
123
|
+
if (!/\btype=["']application\/ld\+json["']/i.test(match[1])) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseJson(match[2]);
|
|
127
|
+
for (const entry of jsonLdEntries(parsed)) {
|
|
128
|
+
if (hasSchemaType(entry, "VideoObject") || hasSchemaType(entry, "SocialMediaPosting")) {
|
|
129
|
+
return entry;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseJson(value: string): unknown {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(value);
|
|
139
|
+
} catch {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(decodeHtml(value));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function jsonLdEntries(value: unknown): Json[] {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.filter(object);
|
|
151
|
+
}
|
|
152
|
+
if (!object(value)) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
return Array.isArray(value["@graph"]) ? value["@graph"].filter(object) : [value];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasSchemaType(value: Json, expected: string): boolean {
|
|
159
|
+
const type = value["@type"];
|
|
160
|
+
return type === expected || (Array.isArray(type) && type.includes(expected));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function linkedinItems(post: Json, html: string, id: string, referer: string): MediaItem[] {
|
|
164
|
+
const headers = { referer, "user-agent": browserUserAgent() };
|
|
165
|
+
if (hasSchemaType(post, "VideoObject")) {
|
|
166
|
+
const url = bestVideoUrl(html) ?? string(post.contentUrl);
|
|
167
|
+
return url
|
|
168
|
+
? [{ filename: filename(`linkedin_${id}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "linkedin", url }]
|
|
169
|
+
: [];
|
|
170
|
+
}
|
|
171
|
+
const url = imageUrl(post.image);
|
|
172
|
+
return url
|
|
173
|
+
? [{ filename: filename(`linkedin_${id}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "linkedin", url }]
|
|
174
|
+
: [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bestVideoUrl(html: string): string | null {
|
|
178
|
+
const encoded = html.match(/<video\b[^>]*\bdata-sources=(["'])(.*?)\1[^>]*>/is)?.[2];
|
|
179
|
+
if (!encoded) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(decodeHtml(encoded));
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const sources = Array.isArray(parsed) ? parsed.filter(object) : [];
|
|
189
|
+
const videos = sources.filter((source) => string(source.type) === "video/mp4" && string(source.src));
|
|
190
|
+
const selected = videos.reduce<Json | null>((best, candidate) => {
|
|
191
|
+
const bitrate = count(candidate["data-bitrate"]) ?? 0;
|
|
192
|
+
const bestBitrate = best ? count(best["data-bitrate"]) ?? 0 : -1;
|
|
193
|
+
return bitrate > bestBitrate ? candidate : best;
|
|
194
|
+
}, null);
|
|
195
|
+
return selected ? string(selected.src) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function imageUrl(value: unknown): string | null {
|
|
199
|
+
if (typeof value === "string") {
|
|
200
|
+
return string(value);
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value.map(imageUrl).find((url): url is string => Boolean(url)) ?? null;
|
|
204
|
+
}
|
|
205
|
+
return object(value) ? string(value.url) : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function interactionCount(value: unknown, action: string): number | undefined {
|
|
209
|
+
const statistics = Array.isArray(value) ? value.filter(object) : object(value) ? [value] : [];
|
|
210
|
+
const statistic = statistics.find((entry) => string(entry.interactionType)?.endsWith(`/${action}`));
|
|
211
|
+
return statistic ? count(statistic.userInteractionCount) : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function linkedinHandle(value: string | null): string | null {
|
|
215
|
+
if (!value) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
return new URL(value).pathname.match(/^\/(?:company|in|showcase)\/([^/]+)/)?.[1] ?? null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function decodeHtml(value: string): string {
|
|
226
|
+
return value.replace(/&(quot|amp|lt|gt|#39|#x27|#(\d+)|#x([0-9a-f]+));/gi, (_entity, name: string, decimal: string, hex: string) => {
|
|
227
|
+
switch (name.toLowerCase()) {
|
|
228
|
+
case "quot":
|
|
229
|
+
return '"';
|
|
230
|
+
case "amp":
|
|
231
|
+
return "&";
|
|
232
|
+
case "lt":
|
|
233
|
+
return "<";
|
|
234
|
+
case "gt":
|
|
235
|
+
return ">";
|
|
236
|
+
case "#39":
|
|
237
|
+
case "#x27":
|
|
238
|
+
return "'";
|
|
239
|
+
default:
|
|
240
|
+
return String.fromCodePoint(decimal ? Number.parseInt(decimal, 10) : Number.parseInt(hex, 16));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/postfetch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { asUrl, createNet, PostfetchError, type Platform, type PostfetchResult } from "./internal";
|
|
2
2
|
import { resolveFacebook } from "./facebook";
|
|
3
3
|
import { resolveInstagram } from "./instagram";
|
|
4
|
+
import { resolveLinkedin } from "./linkedin";
|
|
4
5
|
import { resolvePinterest } from "./pinterest";
|
|
5
6
|
import { resolveReddit } from "./reddit";
|
|
6
7
|
import { resolveSoundcloud } from "./soundcloud";
|
|
@@ -24,7 +25,7 @@ export type PostfetchOptions = {
|
|
|
24
25
|
* performs no side effects beyond the lookup; use {@link download}, {@link archive}
|
|
25
26
|
* or {@link toResponse} to turn the result into bytes or a `Response`.
|
|
26
27
|
*
|
|
27
|
-
* @param url A post URL from
|
|
28
|
+
* @param url A post URL from a supported platform.
|
|
28
29
|
* @param options See {@link PostfetchOptions}.
|
|
29
30
|
* @returns The resolved post and its media items.
|
|
30
31
|
* @throws {PostfetchError} If the URL is empty or its host is unsupported.
|
|
@@ -52,6 +53,8 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
|
|
|
52
53
|
return resolveFacebook(context);
|
|
53
54
|
case "instagram":
|
|
54
55
|
return resolveInstagram(context);
|
|
56
|
+
case "linkedin":
|
|
57
|
+
return resolveLinkedin(context);
|
|
55
58
|
case "pinterest":
|
|
56
59
|
return resolvePinterest(context);
|
|
57
60
|
case "reddit":
|
|
@@ -82,6 +85,9 @@ export function detect(url: string): Platform {
|
|
|
82
85
|
if (host.includes("instagram.com")) {
|
|
83
86
|
return "instagram";
|
|
84
87
|
}
|
|
88
|
+
if (host === "linkedin.com" || host.endsWith(".linkedin.com")) {
|
|
89
|
+
return "linkedin";
|
|
90
|
+
}
|
|
85
91
|
if (host.includes("pinterest.") || host === "pin.it") {
|
|
86
92
|
return "pinterest";
|
|
87
93
|
}
|
|
@@ -100,5 +106,5 @@ export function detect(url: string): Platform {
|
|
|
100
106
|
if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
|
|
101
107
|
return "twitter";
|
|
102
108
|
}
|
|
103
|
-
throw new PostfetchError(400, "only Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
109
|
+
throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
104
110
|
}
|