@postfetch/core 0.12.4 → 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. */
@@ -33,6 +33,8 @@ export type MediaItem = {
33
33
  mime: string;
34
34
  /** The platform this item came from. */
35
35
  platform: Platform;
36
+ /** Website preview image, displayed below the post's content rather than in its attachment gallery. */
37
+ role?: "linkPreview";
36
38
  /** Direct CDN URL of the media. */
37
39
  url: string;
38
40
  };
@@ -142,6 +144,15 @@ export type SoundcloudExtra = {
142
144
  /** Track duration in seconds. */
143
145
  durationSeconds?: number;
144
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
+ };
145
156
  /** Pinterest-specific metadata. */
146
157
  export type PinterestExtra = {
147
158
  /** Times the pin was saved (repinned). */
@@ -163,7 +174,7 @@ export type PostComment = {
163
174
  items: MediaItem[];
164
175
  };
165
176
  /** The result of resolving a post: its platform, id, media items and metadata. */
166
- 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>;
167
178
  /** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
168
179
  export type PlatformResult<P extends Platform, Extra> = {
169
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.4",
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";
@@ -32,6 +32,8 @@ export type MediaItem = {
32
32
  mime: string;
33
33
  /** The platform this item came from. */
34
34
  platform: Platform;
35
+ /** Website preview image, displayed below the post's content rather than in its attachment gallery. */
36
+ role?: "linkPreview";
35
37
  /** Direct CDN URL of the media. */
36
38
  url: string;
37
39
  };
@@ -149,6 +151,16 @@ export type SoundcloudExtra = {
149
151
  durationSeconds?: number;
150
152
  };
151
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
+
152
164
  /** Pinterest-specific metadata. */
153
165
  export type PinterestExtra = {
154
166
  /** Times the pin was saved (repinned). */
@@ -179,6 +191,7 @@ export type PostfetchResult =
179
191
  | PlatformResult<"pinterest", PinterestExtra>
180
192
  | PlatformResult<"reddit", RedditExtra>
181
193
  | PlatformResult<"soundcloud", SoundcloudExtra>
194
+ | PlatformResult<"spotify", SpotifyExtra>
182
195
  | PlatformResult<"tiktok", TiktokExtra>
183
196
  | PlatformResult<"twitter", TwitterExtra>
184
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
+ }
package/src/twitter.ts CHANGED
@@ -33,13 +33,20 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
33
33
  throw new Error("Tweet is unavailable or age-restricted");
34
34
  }
35
35
  await expandTwitterText(input.net, tweet, id);
36
- const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
36
+ const posts = twitterTweets(tweet, id);
37
+ const groups = posts.flatMap(({ id: postId, tweet: post }) => {
37
38
  const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
38
39
  return media.flatMap((entry, index) => {
39
40
  const candidates = twitterCandidates(entry, postId, index + 1);
40
41
  return candidates.length > 0 ? [candidates] : [];
41
42
  });
42
43
  });
44
+ // Quotes nest inside their containing post, so append each card as we unwind
45
+ // the quote chain. Download order then matches the displayed post order.
46
+ for (const { id: postId, tweet: post } of posts.toReversed()) {
47
+ const card = twitterCardImage(post, postId);
48
+ if (card) groups.push([{ bitrate: 0, item: card }]);
49
+ }
43
50
  const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
44
51
  // A text-only tweet is a valid result with metadata and no media. Syndication
45
52
  // has already failed or returned a tombstone when the tweet is unavailable,
@@ -213,6 +220,41 @@ function twitterCandidates(entry: Json, id: string, index: number): TwitterCandi
213
220
  : [];
214
221
  }
215
222
 
223
+ function twitterCardImage(tweet: Json, id: string): MediaItem | null {
224
+ const card = object(tweet.card) ? tweet.card : null;
225
+ if (!card || (card.name !== "summary" && card.name !== "summary_large_image")) return null;
226
+ const bindings = object(card.binding_values) ? card.binding_values : {};
227
+ const images = Object.entries(bindings).flatMap(([key, value]) => {
228
+ // Select a single preview, excluding site icons and unrelated card assets.
229
+ if (!/^(photo_image_full_size|summary_photo_image|thumbnail_image)(_(original|x_large|large|small))?$/.test(key)
230
+ || !object(value) || !object(value.image_value)) return [];
231
+ const image = value.image_value;
232
+ const rawUrl = string(image.url);
233
+ if (!rawUrl) return [];
234
+ try {
235
+ const url = new URL(rawUrl);
236
+ if (url.protocol !== "https:" && url.protocol !== "http:") return [];
237
+ const format = (url.searchParams.get("format") ?? url.pathname.match(/\.(jpe?g|png|webp|gif)$/i)?.[1] ?? "jpg").toLowerCase();
238
+ const extension = format === "jpeg" ? "jpg" : format;
239
+ if (!["jpg", "png", "webp", "gif"].includes(extension)) return [];
240
+ return [{
241
+ url: url.href, extension,
242
+ original: key.endsWith("_original") ? 1 : 0,
243
+ area: Math.max(0, number(image.width) ?? 0) * Math.max(0, number(image.height) ?? 0),
244
+ }];
245
+ } catch {
246
+ return [];
247
+ }
248
+ }).sort((a, b) => b.original - a.original || b.area - a.area);
249
+ const image = images[0];
250
+ return image ? {
251
+ filename: filename(`twitter_${id}_preview.${image.extension}`),
252
+ headers: { "user-agent": browserUserAgent() },
253
+ id, kind: "image", mime: `image/${image.extension === "jpg" ? "jpeg" : image.extension}`,
254
+ platform: "twitter", role: "linkPreview", url: image.url,
255
+ } : null;
256
+ }
257
+
216
258
  function videoVariants(entry: Json): Array<{ bitrate: number; url: string }> {
217
259
  const info = object(entry.video_info) ? entry.video_info : null;
218
260
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];