@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.
- package/README.md +10 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1009 -836
- package/dist/internal.d.ts +11 -2
- package/dist/spotify.d.ts +3 -0
- package/package.json +3 -2
- package/src/download.ts +9 -3
- package/src/index.ts +4 -2
- package/src/internal.ts +12 -1
- package/src/postfetch.ts +7 -1
- package/src/spotify.ts +170 -0
package/dist/internal.d.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
|
/** 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.
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
5
|
-
*
|
|
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
|
+
}
|