@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.
- package/README.md +10 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1009 -788
- package/dist/internal.d.ts +13 -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 +14 -1
- package/src/postfetch.ts +7 -1
- package/src/spotify.ts +170 -0
- package/src/twitter.ts +43 -1
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. */
|
|
@@ -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.
|
|
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";
|
|
@@ -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
|
|
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) : [];
|