@postfetch/core 0.12.5 → 0.13.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.
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "spotify" | "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. */
@@ -144,6 +144,15 @@ export type SoundcloudExtra = {
144
144
  /** Track duration in seconds. */
145
145
  durationSeconds?: number;
146
146
  };
147
+ /** Spotify track metadata, with the YouTube source selected for downloading. */
148
+ export type SpotifyExtra = {
149
+ /** Artists credited on Spotify, in order. */
150
+ artists: string[];
151
+ /** Spotify track duration in seconds, when available. */
152
+ durationSeconds?: number;
153
+ /** Public watch URL passed to the existing YouTube resolver. */
154
+ youtubeUrl: string;
155
+ };
147
156
  /** Pinterest-specific metadata. */
148
157
  export type PinterestExtra = {
149
158
  /** Times the pin was saved (repinned). */
@@ -165,7 +174,7 @@ export type PostComment = {
165
174
  items: MediaItem[];
166
175
  };
167
176
  /** The result of resolving a post: its platform, id, media items and metadata. */
168
- 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>;
177
+ export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"spotify", SpotifyExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
169
178
  /** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
170
179
  export type PlatformResult<P extends Platform, Extra> = {
171
180
  /** Suggested filename when zipping every item. */
@@ -0,0 +1,3 @@
1
+ import { type PlatformResult, type ResolveContext, type SpotifyExtra } from "./internal";
2
+ /** Resolve Spotify track metadata and a matching video through YouTube. */
3
+ export declare function resolveSpotify(input: ResolveContext): Promise<PlatformResult<"spotify", SpotifyExtra>>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.12.5",
4
- "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
3
+ "version": "0.13.0",
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",
7
7
  "exports": {
@@ -35,6 +35,7 @@
35
35
  "pinterest",
36
36
  "reddit",
37
37
  "soundcloud",
38
+ "spotify",
38
39
  "tiktok",
39
40
  "twitter",
40
41
  "youtube",
package/src/download.ts CHANGED
@@ -185,10 +185,16 @@ export async function archive(result: PostfetchResult, options: DownloadOptions
185
185
  * @returns A `Response` a server or edge function can return directly.
186
186
  */
187
187
  export async function toResponse(result: PostfetchResult, options: DownloadOptions = {}): Promise<Response> {
188
- if (result.items.length === 1) {
189
- return singleResponse(result.items[0], options);
188
+ const response = result.items.length === 1
189
+ ? await singleResponse(result.items[0], options)
190
+ : await archiveResponse(result, options);
191
+ if (result.platform === "spotify") {
192
+ response.headers.set("x-media-platform", result.platform);
193
+ response.headers.set("x-media-id", result.id);
194
+ const youtubeUrl = result.metadata?.extra?.youtubeUrl;
195
+ if (youtubeUrl) response.headers.set("x-media-youtube-url", youtubeUrl);
190
196
  }
191
- return archiveResponse(result, options);
197
+ return response;
192
198
  }
193
199
 
194
200
  async function singleResponse(item: MediaItem, options: DownloadOptions): Promise<Response> {
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Turn a public social post URL into its media.
3
3
  *
4
- * {@link postfetch} resolves Instagram, LinkedIn, TikTok, YouTube, Facebook and X (Twitter)
5
- * URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
4
+ * {@link postfetch} resolves public social post and Spotify track URLs to typed
5
+ * {@link MediaItem}s. Spotify tracks are matched to YouTube videos.
6
+ * Zero dependencies, only Web-standard APIs
6
7
  * (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
7
8
  * Cloudflare Workers and browsers alike. It only resolves the media; the
8
9
  * {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
@@ -46,6 +47,7 @@ export type {
46
47
  PostMetadata,
47
48
  RedditExtra,
48
49
  SoundcloudExtra,
50
+ SpotifyExtra,
49
51
  TiktokExtra,
50
52
  TwitterExtra,
51
53
  TwitterQuotedTweet,
package/src/internal.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "spotify" | "tiktok" | "twitter" | "youtube";
3
3
 
4
4
  /** The kind of a {@link MediaItem}. */
5
5
  export type MediaKind = "audio" | "image" | "video";
@@ -151,6 +151,16 @@ export type SoundcloudExtra = {
151
151
  durationSeconds?: number;
152
152
  };
153
153
 
154
+ /** Spotify track metadata, with the YouTube source selected for downloading. */
155
+ export type SpotifyExtra = {
156
+ /** Artists credited on Spotify, in order. */
157
+ artists: string[];
158
+ /** Spotify track duration in seconds, when available. */
159
+ durationSeconds?: number;
160
+ /** Public watch URL passed to the existing YouTube resolver. */
161
+ youtubeUrl: string;
162
+ };
163
+
154
164
  /** Pinterest-specific metadata. */
155
165
  export type PinterestExtra = {
156
166
  /** Times the pin was saved (repinned). */
@@ -181,6 +191,7 @@ export type PostfetchResult =
181
191
  | PlatformResult<"pinterest", PinterestExtra>
182
192
  | PlatformResult<"reddit", RedditExtra>
183
193
  | PlatformResult<"soundcloud", SoundcloudExtra>
194
+ | PlatformResult<"spotify", SpotifyExtra>
184
195
  | PlatformResult<"tiktok", TiktokExtra>
185
196
  | PlatformResult<"twitter", TwitterExtra>
186
197
  | PlatformResult<"youtube", YoutubeExtra>;
package/src/postfetch.ts CHANGED
@@ -5,6 +5,7 @@ import { resolveLinkedin } from "./linkedin";
5
5
  import { resolvePinterest } from "./pinterest";
6
6
  import { resolveReddit } from "./reddit";
7
7
  import { resolveSoundcloud } from "./soundcloud";
8
+ import { resolveSpotify } from "./spotify";
8
9
  import { resolveTiktok } from "./tiktok";
9
10
  import { resolveTwitter } from "./twitter";
10
11
  import { resolveYoutube } from "./youtube";
@@ -85,6 +86,8 @@ async function resolve(context: ResolveContext): Promise<PostfetchResult> {
85
86
  return resolveReddit(context);
86
87
  case "soundcloud":
87
88
  return resolveSoundcloud(context);
89
+ case "spotify":
90
+ return resolveSpotify(context);
88
91
  case "tiktok":
89
92
  return resolveTiktok(context);
90
93
  case "twitter":
@@ -180,6 +183,9 @@ export function detect(url: string): Platform {
180
183
  if (host.includes("soundcloud.com") || host === "snd.sc") {
181
184
  return "soundcloud";
182
185
  }
186
+ if (host === "open.spotify.com") {
187
+ return "spotify";
188
+ }
183
189
  if (host.includes("youtube.com") || host === "youtu.be") {
184
190
  return "youtube";
185
191
  }
@@ -189,5 +195,5 @@ export function detect(url: string): Platform {
189
195
  if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
190
196
  return "twitter";
191
197
  }
192
- throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
198
+ throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube URLs are supported");
193
199
  }
package/src/spotify.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { browserUserAgent } from "./fingerprint";
2
+ import { asUrl, filename, number, object, PostfetchError, string, type Json, type PlatformResult, type ResolveContext, type SpotifyExtra } from "./internal";
3
+ import { resolveYoutube } from "./youtube";
4
+
5
+ type Track = { title: string; artists: string[]; durationSeconds?: number };
6
+
7
+ /** Resolve Spotify track metadata and a matching video through YouTube. */
8
+ export async function resolveSpotify(input: ResolveContext): Promise<PlatformResult<"spotify", SpotifyExtra>> {
9
+ const url = asUrl(input.url);
10
+ const id = url.pathname.match(/^\/(?:intl-[a-z-]+\/)?(?:embed\/)?track\/([A-Za-z0-9]{22})\/?$/i)?.[1];
11
+ if (!id) {
12
+ throw new PostfetchError(400, "Spotify requires an open.spotify.com track URL");
13
+ }
14
+ const track = await spotifyTrack(input, id);
15
+ const youtubeUrl = await matchYoutube(input, track);
16
+ // Keep YouTube's media, filenames, separate audio and client fallback intact.
17
+ const youtube = await resolveYoutube({ ...input, url: youtubeUrl });
18
+ return {
19
+ archiveFilename: filename(`spotify_${id}.zip`),
20
+ comments: [],
21
+ id,
22
+ items: youtube.items,
23
+ platform: "spotify",
24
+ metadata: {
25
+ title: track.title,
26
+ author: { name: track.artists.join(", ") },
27
+ extra: { artists: track.artists, durationSeconds: track.durationSeconds, youtubeUrl },
28
+ },
29
+ };
30
+ }
31
+
32
+ async function spotifyTrack(input: ResolveContext, id: string): Promise<Track> {
33
+ const response = await input.net(`https://open.spotify.com/embed/track/${id}`, {
34
+ headers: { "user-agent": browserUserAgent() },
35
+ });
36
+ if (!response.ok) {
37
+ throw new PostfetchError(response.status === 404 ? 404 : 502, `Spotify track lookup failed: ${response.status}`);
38
+ }
39
+ const html = await response.text();
40
+ const raw = html.match(/<script\b[^>]*\bid=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)?.[1];
41
+ const payload = parseJson(raw);
42
+ const props = payload && object(payload.props) ? payload.props : null;
43
+ const page = props && object(props.pageProps) ? props.pageProps : null;
44
+ const state = page && object(page.state) ? page.state : null;
45
+ const data = state && object(state.data) ? state.data : null;
46
+ const entity = data && object(data.entity) ? data.entity : null;
47
+ const title = entity && (string(entity.title) ?? string(entity.name));
48
+ const artists = entity && Array.isArray(entity.artists)
49
+ ? entity.artists.filter(object).map((artist) => string(artist.name)).filter((name): name is string => name !== null)
50
+ : [];
51
+ if (!entity || entity.type !== "track" || entity.id !== id || !title || artists.length === 0) {
52
+ throw new PostfetchError(502, "Spotify track metadata not found");
53
+ }
54
+ const duration = number(entity.duration);
55
+ return { title, artists, durationSeconds: duration !== null && duration > 0 ? duration / 1000 : undefined };
56
+ }
57
+
58
+ async function matchYoutube(input: ResolveContext, track: Track): Promise<string> {
59
+ const url = new URL("https://www.youtube.com/results");
60
+ url.searchParams.set("search_query", `${track.artists.join(" ")} ${track.title} audio`);
61
+ url.searchParams.set("hl", "en");
62
+ const response = await input.net(url.href, {
63
+ headers: { "accept-language": "en-US,en;q=0.9", cookie: "SOCS=CAI", "user-agent": browserUserAgent() },
64
+ });
65
+ if (!response.ok) {
66
+ throw new PostfetchError(502, `YouTube search failed: ${response.status}`);
67
+ }
68
+ const html = await response.text();
69
+ // Read the JSON assignment up to the end of its script, without evaluating JS.
70
+ // The balanced JSON object is extracted before parsing; trailing JS is ignored.
71
+ const raw = html.match(/(?:\bytInitialData|window\[["']ytInitialData["']\])\s*=\s*(\{[\s\S]*?)<\/script>/)?.[1];
72
+ const payload = parseAssignment(raw);
73
+ if (!payload || !object(payload.contents)) {
74
+ throw new PostfetchError(502, "YouTube search results not found");
75
+ }
76
+ const candidates = videoRenderers(payload.contents);
77
+ const ranked = candidates.map((candidate, index) => ({ candidate, score: matchScore(track, candidate) - index / 1000 }))
78
+ .filter(({ score }) => Number.isFinite(score))
79
+ .sort((left, right) => right.score - left.score);
80
+ const id = ranked[0]?.candidate.videoId;
81
+ if (typeof id !== "string") {
82
+ throw new PostfetchError(404, "No matching YouTube video found for this Spotify track", "notFound");
83
+ }
84
+ return `https://www.youtube.com/watch?v=${id}`;
85
+ }
86
+
87
+ function videoRenderers(value: unknown): Json[] {
88
+ if (Array.isArray(value)) return value.flatMap(videoRenderers);
89
+ if (!object(value)) return [];
90
+ if (object(value.videoRenderer)) return [value.videoRenderer];
91
+ return Object.values(value).flatMap(videoRenderers);
92
+ }
93
+
94
+ function text(value: unknown): string {
95
+ if (!object(value)) return "";
96
+ return string(value.simpleText) ?? (Array.isArray(value.runs)
97
+ ? value.runs.filter(object).map((run) => typeof run.text === "string" ? run.text : "").join("")
98
+ : "");
99
+ }
100
+
101
+ function normalize(value: string): string {
102
+ return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
103
+ }
104
+
105
+ function contains(haystack: string, needle: string): boolean {
106
+ return needle.length > 0 && ` ${haystack} `.includes(` ${needle} `);
107
+ }
108
+
109
+ function durationSeconds(value: string): number | null {
110
+ if (!/^\d+(?::\d{2}){1,2}$/.test(value)) return null;
111
+ return value.split(":").reduce((seconds, part) => seconds * 60 + Number(part), 0);
112
+ }
113
+
114
+ function matchScore(track: Track, candidate: Json): number {
115
+ if (typeof candidate.videoId !== "string" || !/^[A-Za-z0-9_-]{11}$/.test(candidate.videoId)) return -Infinity;
116
+ const title = normalize(text(candidate.title));
117
+ const author = normalize(text(candidate.ownerText) || text(candidate.longBylineText) || text(candidate.shortBylineText));
118
+ const expected = normalize(track.title.replace(/\s*[([](?:feat\.?|ft\.?).*?[)\]]/gi, ""));
119
+ const tokens = expected.split(" ").filter(Boolean);
120
+ if (tokens.length === 0) return -Infinity;
121
+ const coverage = tokens.filter((token) => contains(title, token)).length / tokens.length;
122
+ if (coverage < (tokens.length <= 3 ? 1 : 0.8)) return -Infinity;
123
+ if (tokens.length <= 3 && !contains(title, expected)) return -Infinity;
124
+ const artists = track.artists.map(normalize);
125
+ if (!contains(`${title} ${author}`, artists[0])) return -Infinity;
126
+ const duration = durationSeconds(text(candidate.lengthText));
127
+ if (duration === null || duration <= 0) return -Infinity; // Excludes live streams and upcoming videos.
128
+ const tolerance = Math.min(30, Math.max(12, (track.durationSeconds ?? duration) * 0.08));
129
+ const delta = track.durationSeconds === undefined ? 0 : Math.abs(duration - track.durationSeconds);
130
+ if (delta > tolerance) return -Infinity;
131
+ // Do not silently substitute a different recording or speed edit. Artist names
132
+ // are removed first so e.g. a band called "Live" is not treated as a live edit.
133
+ let recordingTitle = title;
134
+ for (const artist of artists) recordingTitle = recordingTitle.replace(artist, "");
135
+ for (const version of [/\bremix\b/, /\bcover\b/, /\bkaraoke\b/, /\binstrumental\b/, /\bslowed\b/, /\b(?:sped|speed) up\b/, /\bnightcore\b/, /\blive\b/, /\bacoustic\b/]) {
136
+ if (version.test(recordingTitle) !== version.test(expected)) return -Infinity;
137
+ }
138
+ const artistUpload = contains(author, artists[0]);
139
+ const credited = artists.filter((artist) => contains(`${title} ${author}`, artist)).length / artists.length;
140
+ return coverage * 10 + (artistUpload ? 8 : 0) + credited * 2 + (contains(title, "official") ? 2 : 0) + 3 * (1 - delta / tolerance);
141
+ }
142
+
143
+ function parseJson(value: string | undefined): Json | null {
144
+ try {
145
+ const parsed: unknown = JSON.parse(value ?? "");
146
+ return object(parsed) ? parsed : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function parseAssignment(value: string | undefined): Json | null {
153
+ if (!value) return null;
154
+ // Balance braces outside JSON strings so titles containing `};` cannot truncate
155
+ // the payload, and trailing statements in the same script are ignored.
156
+ let depth = 0;
157
+ let quoted = false;
158
+ let escaped = false;
159
+ for (let index = 0; index < value.length; index += 1) {
160
+ const character = value[index];
161
+ if (quoted) {
162
+ if (escaped) escaped = false;
163
+ else if (character === "\\") escaped = true;
164
+ else if (character === '"') quoted = false;
165
+ } else if (character === '"') quoted = true;
166
+ else if (character === "{") depth += 1;
167
+ else if (character === "}" && --depth === 0) return parseJson(value.slice(0, index + 1));
168
+ }
169
+ return null;
170
+ }