@postfetch/core 0.13.1 → 0.13.3

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 CHANGED
@@ -13,6 +13,8 @@ for (const item of result.items) {
13
13
 
14
14
  `postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `downloadBlob`, `archive` and `toResponse` materialize the result.
15
15
 
16
+ LinkedIn public posts also accept `lnkd.in` shortlinks, including `https://lnkd.in/p/…`.
17
+
16
18
  Spotify track URLs are matched to YouTube by artist, title and duration, preferring
17
19
  artist uploads and rejecting recognizable alternate versions unless requested.
18
20
  Results have `platform: "spotify"`, Spotify metadata and
package/dist/index.js CHANGED
@@ -796,21 +796,10 @@ function randomToken(length = 8) {
796
796
 
797
797
  // src/linkedin.ts
798
798
  async function resolveLinkedin(input) {
799
- const reference = postReference(input.url);
799
+ const reference = await postReference(input);
800
800
  const pageUrl = `https://www.linkedin.com/feed/update/urn:li:${reference.kind}:${reference.id}`;
801
801
  const response = await input.net(pageUrl, { headers: navigationHeaders() });
802
- if (response.status === 404) {
803
- throw new PostfetchError(404, "LinkedIn post not found", "notFound");
804
- }
805
- if (response.status === 429 || response.status === 999) {
806
- throw new PostfetchError(429, "LinkedIn rate limit reached", "rateLimited");
807
- }
808
- if (!response.ok) {
809
- throw new PostfetchError(response.status, "LinkedIn post unavailable", "unavailable");
810
- }
811
- if (response.url.includes("/signup/") || response.url.includes("/login")) {
812
- throw new PostfetchError(401, "LinkedIn login required", "loginRequired");
813
- }
802
+ checkResponse(response);
814
803
  const html = await response.text();
815
804
  const post = linkedinPost(html);
816
805
  if (!post) {
@@ -846,8 +835,31 @@ function linkedinMetadata(post) {
846
835
  commentCount: count(post.commentCount) ?? interactionCount(post.interactionStatistic, "CommentAction")
847
836
  };
848
837
  }
849
- function postReference(input) {
850
- const reference = postReferenceOrNull(input);
838
+ function checkResponse(response) {
839
+ if (response.status === 404) {
840
+ throw new PostfetchError(404, "LinkedIn post not found", "notFound");
841
+ }
842
+ if (response.status === 429 || response.status === 999) {
843
+ throw new PostfetchError(429, "LinkedIn rate limit reached", "rateLimited");
844
+ }
845
+ if (!response.ok) {
846
+ throw new PostfetchError(response.status, "LinkedIn post unavailable", "unavailable");
847
+ }
848
+ if (response.url.includes("/signup/") || response.url.includes("/login")) {
849
+ throw new PostfetchError(401, "LinkedIn login required", "loginRequired");
850
+ }
851
+ }
852
+ async function postReference(input) {
853
+ let url = input.url;
854
+ if (new URL(url).hostname === "lnkd.in") {
855
+ const response = await input.net(url, { headers: navigationHeaders(), redirect: "manual" });
856
+ await response.body?.cancel();
857
+ if (![301, 302, 303, 307, 308].includes(response.status)) {
858
+ checkResponse(response);
859
+ }
860
+ url = response.headers.get("location") ?? "";
861
+ }
862
+ const reference = postReferenceOrNull(url);
851
863
  if (!reference) {
852
864
  throw new PostfetchError(400, "LinkedIn post id not found");
853
865
  }
@@ -856,7 +868,11 @@ function postReference(input) {
856
868
  function postReferenceOrNull(input) {
857
869
  let pathname;
858
870
  try {
859
- pathname = new URL(input).pathname.replace(/%3A/gi, ":");
871
+ const url = new URL(input);
872
+ if (url.hostname !== "linkedin.com" && !url.hostname.endsWith(".linkedin.com")) {
873
+ return null;
874
+ }
875
+ pathname = url.pathname.replace(/%3A/gi, ":");
860
876
  } catch {
861
877
  return null;
862
878
  }
@@ -1626,14 +1642,17 @@ async function appToken(net) {
1626
1642
  return token;
1627
1643
  }
1628
1644
  async function fetchPost(net, token, id) {
1629
- const response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, {
1630
- headers: { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() }
1631
- });
1645
+ const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
1646
+ let response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, { headers });
1647
+ if (response.status === 403) {
1648
+ await response.body?.cancel();
1649
+ response = await net(`https://oauth.reddit.com/by_id/t3_${id}?raw_json=1`, { headers });
1650
+ }
1632
1651
  if (!response.ok) {
1633
1652
  throw new Error(`Reddit post failed: ${response.status}`);
1634
1653
  }
1635
1654
  const payload = await response.json().catch(() => null);
1636
- const listing = Array.isArray(payload) ? payload[0] : null;
1655
+ const listing = Array.isArray(payload) ? payload[0] : payload;
1637
1656
  const children = object(listing) && object(listing.data) && Array.isArray(listing.data.children) ? listing.data.children : [];
1638
1657
  const first = children[0];
1639
1658
  const post = object(first) && object(first.data) ? first.data : null;
@@ -3348,7 +3367,7 @@ function detect(url) {
3348
3367
  if (host.includes("instagram.com")) {
3349
3368
  return "instagram";
3350
3369
  }
3351
- if (host === "linkedin.com" || host.endsWith(".linkedin.com")) {
3370
+ if (host === "linkedin.com" || host.endsWith(".linkedin.com") || host === "lnkd.in") {
3352
3371
  return "linkedin";
3353
3372
  }
3354
3373
  if (host.includes("pinterest.") || host === "pin.it") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/linkedin.ts CHANGED
@@ -21,21 +21,10 @@ type LinkedinPostReference = {
21
21
  };
22
22
 
23
23
  export async function resolveLinkedin(input: ResolveContext): Promise<PostfetchResult> {
24
- const reference = postReference(input.url);
24
+ const reference = await postReference(input);
25
25
  const pageUrl = `https://www.linkedin.com/feed/update/urn:li:${reference.kind}:${reference.id}`;
26
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
- }
27
+ checkResponse(response);
39
28
 
40
29
  const html = await response.text();
41
30
  const post = linkedinPost(html);
@@ -76,8 +65,33 @@ export function linkedinMetadata(post: Json): PostMetadata {
76
65
  };
77
66
  }
78
67
 
79
- function postReference(input: string): LinkedinPostReference {
80
- const reference = postReferenceOrNull(input);
68
+ function checkResponse(response: Response): void {
69
+ if (response.status === 404) {
70
+ throw new PostfetchError(404, "LinkedIn post not found", "notFound");
71
+ }
72
+ if (response.status === 429 || response.status === 999) {
73
+ throw new PostfetchError(429, "LinkedIn rate limit reached", "rateLimited");
74
+ }
75
+ if (!response.ok) {
76
+ throw new PostfetchError(response.status, "LinkedIn post unavailable", "unavailable");
77
+ }
78
+ if (response.url.includes("/signup/") || response.url.includes("/login")) {
79
+ throw new PostfetchError(401, "LinkedIn login required", "loginRequired");
80
+ }
81
+ }
82
+
83
+ async function postReference(input: ResolveContext): Promise<LinkedinPostReference> {
84
+ let url = input.url;
85
+ if (new URL(url).hostname === "lnkd.in") {
86
+ // Read the destination without fetching the post twice or following outbound links.
87
+ const response = await input.net(url, { headers: navigationHeaders(), redirect: "manual" });
88
+ await response.body?.cancel();
89
+ if (![301, 302, 303, 307, 308].includes(response.status)) {
90
+ checkResponse(response);
91
+ }
92
+ url = response.headers.get("location") ?? "";
93
+ }
94
+ const reference = postReferenceOrNull(url);
81
95
  if (!reference) {
82
96
  throw new PostfetchError(400, "LinkedIn post id not found");
83
97
  }
@@ -87,7 +101,11 @@ function postReference(input: string): LinkedinPostReference {
87
101
  function postReferenceOrNull(input: string): LinkedinPostReference | null {
88
102
  let pathname: string;
89
103
  try {
90
- pathname = new URL(input).pathname.replace(/%3A/gi, ":");
104
+ const url = new URL(input);
105
+ if (url.hostname !== "linkedin.com" && !url.hostname.endsWith(".linkedin.com")) {
106
+ return null;
107
+ }
108
+ pathname = url.pathname.replace(/%3A/gi, ":");
91
109
  } catch {
92
110
  return null;
93
111
  }
package/src/postfetch.ts CHANGED
@@ -171,7 +171,7 @@ export function detect(url: string): Platform {
171
171
  if (host.includes("instagram.com")) {
172
172
  return "instagram";
173
173
  }
174
- if (host === "linkedin.com" || host.endsWith(".linkedin.com")) {
174
+ if (host === "linkedin.com" || host.endsWith(".linkedin.com") || host === "lnkd.in") {
175
175
  return "linkedin";
176
176
  }
177
177
  if (host.includes("pinterest.") || host === "pin.it") {
package/src/reddit.ts CHANGED
@@ -100,14 +100,19 @@ async function appToken(net: Net): Promise<string> {
100
100
  }
101
101
 
102
102
  async function fetchPost(net: Net, token: string, id: string): Promise<Json> {
103
- const response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, {
104
- headers: { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() },
105
- });
103
+ const headers = { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() };
104
+ let response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, { headers });
105
+ // Reddit can block the comments route while still allowing direct post
106
+ // listings with the same token. This route returns the post, not its replies.
107
+ if (response.status === 403) {
108
+ await response.body?.cancel();
109
+ response = await net(`https://oauth.reddit.com/by_id/t3_${id}?raw_json=1`, { headers });
110
+ }
106
111
  if (!response.ok) {
107
112
  throw new Error(`Reddit post failed: ${response.status}`);
108
113
  }
109
114
  const payload = await response.json().catch(() => null);
110
- const listing = Array.isArray(payload) ? payload[0] : null;
115
+ const listing = Array.isArray(payload) ? payload[0] : payload;
111
116
  const children = object(listing) && object(listing.data) && Array.isArray(listing.data.children) ? listing.data.children : [];
112
117
  const first = children[0];
113
118
  const post = object(first) && object(first.data) ? first.data : null;