@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.5.1",
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
- const url = await embedVideo(input.net, canonical);
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) {
@@ -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
- export function navigationHeaders(): Record<string, string> {
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": fingerprint.acceptLanguage,
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": fingerprint.userAgent,
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 media =
62
- (await pageMedia(input.net, code, input.preferredWidth)) ??
63
- (await mobileMedia(input.net, code, input.preferredWidth)) ??
64
- (await embedMedia(input.net, code)) ??
65
- (await graphqlMedia(input.net, code));
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
- doc_id: "8845758582119845",
314
+ av: "0",
315
+ doc_id: "26130443479876713",
305
316
  fb_api_caller_class: "RelayModern",
306
- fb_api_req_friendly_name: "PolarisPostActionLoadPostQueryQuery",
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": "PolarisPostActionLoadPostQueryQuery",
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
- csrf && `csrftoken=${csrf}`,
349
- polaris?.device_id && `ig_did=${polaris.device_id}`,
350
- polaris?.machine_id && `mid=${polaris.machine_id}`,
351
- "wd=1280x720",
352
- "dpr=2",
353
- "ig_nrcb=1",
354
- ].filter((value): value is string => typeof value === "string" && value.length > 0).join("; ");
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>
@@ -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 Instagram, TikTok, YouTube, Facebook or X.
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
  }