@postfetch/core 0.6.0 → 0.8.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/src/twitter.ts CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  } from "./internal";
18
18
  import { browserUserAgent } from "./fingerprint";
19
19
 
20
+ type TwitterCandidate = {
21
+ bitrate: number;
22
+ item: MediaItem;
23
+ };
24
+
20
25
  export async function resolveTwitter(input: ResolveContext): Promise<PostfetchResult> {
21
26
  const id = tweetId(input.url);
22
27
  if (!id) {
@@ -26,16 +31,32 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
26
31
  if (string(tweet.__typename) === "TweetTombstone") {
27
32
  throw new Error("Tweet is unavailable or age-restricted");
28
33
  }
29
- const media = Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails.filter(object) : [];
30
- const items = media.flatMap((entry, index) => twitterItem(entry, id, index + 1));
31
- if (items.length === 0) {
32
- throw new Error("Twitter media not found");
33
- }
34
+ const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
35
+ const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
36
+ return media.flatMap((entry, index) => {
37
+ const candidates = twitterCandidates(entry, postId, index + 1);
38
+ return candidates.length > 0 ? [candidates] : [];
39
+ });
40
+ });
41
+ const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
42
+ // A text-only tweet is a valid result with metadata and no media. Syndication
43
+ // has already failed or returned a tombstone when the tweet is unavailable,
44
+ // so an empty item list here does not mean the lookup failed.
34
45
  return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
35
46
  }
36
47
 
37
48
  export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterExtra } {
38
49
  const user = object(tweet.user) ? tweet.user : null;
50
+ const extra: TwitterExtra = {};
51
+ const lang = string(tweet.lang);
52
+ if (lang) {
53
+ extra.lang = lang;
54
+ }
55
+ const quotedTweet = object(tweet.quoted_tweet) ? tweet.quoted_tweet : null;
56
+ const quotedId = quotedTweet ? string(quotedTweet.id_str) : null;
57
+ if (quotedTweet && quotedId) {
58
+ extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
59
+ }
39
60
  return {
40
61
  text: (string(tweet.text) ?? string(tweet.full_text)) ?? undefined,
41
62
  author: user
@@ -51,10 +72,28 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
51
72
  shareCount: count(tweet.retweet_count),
52
73
  viewCount: count(tweet.view_count),
53
74
  nsfw: bool(tweet.possibly_sensitive),
54
- extra: { lang: string(tweet.lang) ?? undefined },
75
+ extra,
55
76
  };
56
77
  }
57
78
 
79
+ function twitterTweets(tweet: Json, rootId: string): Array<{ id: string; tweet: Json }> {
80
+ const tweets: Array<{ id: string; tweet: Json }> = [];
81
+ const seen = new Set<string>();
82
+ let current: Json | null = tweet;
83
+ let fallbackId: string | null = rootId;
84
+ while (current) {
85
+ const id = string(current.id_str) ?? fallbackId;
86
+ if (!id || seen.has(id)) {
87
+ break;
88
+ }
89
+ tweets.push({ id, tweet: current });
90
+ seen.add(id);
91
+ current = object(current.quoted_tweet) ? current.quoted_tweet : null;
92
+ fallbackId = null;
93
+ }
94
+ return tweets;
95
+ }
96
+
58
97
  // The public syndication endpoint returns tweet media (video variants + photos)
59
98
  // without a guest token or bearer; the token is a non-validated cache key.
60
99
  async function syndication(net: Net, id: string): Promise<Json> {
@@ -74,32 +113,112 @@ function syndicationToken(id: string): string {
74
113
  return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
75
114
  }
76
115
 
77
- function twitterItem(entry: Json, id: string, index: number): MediaItem[] {
116
+ function twitterCandidates(entry: Json, id: string, index: number): TwitterCandidate[] {
78
117
  const headers = { "user-agent": browserUserAgent() };
79
118
  const type = string(entry.type);
80
119
  if (type === "video" || type === "animated_gif") {
81
- const url = bestVariant(entry);
82
- return url
83
- ? [{ filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }]
84
- : [];
120
+ return videoVariants(entry).map(({ bitrate, url }) => ({
121
+ bitrate,
122
+ item: { filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url },
123
+ }));
85
124
  }
86
125
  const photo = string(entry.media_url_https);
87
126
  return photo
88
- ? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }]
127
+ ? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }]
89
128
  : [];
90
129
  }
91
130
 
92
- function bestVariant(entry: Json): string | null {
131
+ function videoVariants(entry: Json): Array<{ bitrate: number; url: string }> {
93
132
  const info = object(entry.video_info) ? entry.video_info : null;
94
133
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
95
- const best = variants
96
- .filter((variant) => string(variant.content_type) === "video/mp4")
97
- .reduce<Json | null>((current, variant) => {
98
- const bitrate = number(variant.bitrate) ?? 0;
99
- const currentBitrate = current ? number(current.bitrate) ?? 0 : -1;
100
- return bitrate > currentBitrate ? variant : current;
101
- }, null);
102
- return best ? string(best.url) : null;
134
+ const seen = new Set<string>();
135
+ return variants
136
+ .flatMap((variant) => {
137
+ const url = string(variant.url);
138
+ if (string(variant.content_type) !== "video/mp4" || !url || seen.has(url)) {
139
+ return [];
140
+ }
141
+ seen.add(url);
142
+ return [{ bitrate: number(variant.bitrate) ?? 0, url }];
143
+ })
144
+ .sort((left, right) => right.bitrate - left.bitrate);
145
+ }
146
+
147
+ async function sizeLimitedItems(input: ResolveContext, groups: TwitterCandidate[][]): Promise<MediaItem[]> {
148
+ if (groups.length === 0 || input.tryMaxBytes === undefined) {
149
+ return groups.map((group) => group[0].item);
150
+ }
151
+ const sizes = new Map<string, Promise<number | null>>();
152
+ const size = (candidate: TwitterCandidate): Promise<number | null> => {
153
+ const existing = sizes.get(candidate.item.url);
154
+ if (existing) {
155
+ return existing;
156
+ }
157
+ const pending = contentLength(input.net, candidate.item);
158
+ sizes.set(candidate.item.url, pending);
159
+ return pending;
160
+ };
161
+
162
+ const highest = groups.map((group) => group[0]);
163
+ const highestSizes = await Promise.all(highest.map(size));
164
+ const highestTotal = knownTotal(highestSizes);
165
+ if (highestTotal !== null && highestTotal <= input.tryMaxBytes) {
166
+ return highest.map(({ item }) => item);
167
+ }
168
+
169
+ const sized = await Promise.all(
170
+ groups.map(async (group) =>
171
+ Promise.all(group.map(async (candidate) => ({ ...candidate, bytes: await size(candidate) }))),
172
+ ),
173
+ );
174
+ const fitting = bestFitting(sized, input.tryMaxBytes);
175
+ return (fitting ?? groups.map((group) => group.at(-1) as TwitterCandidate)).map(({ item }) => item);
176
+ }
177
+
178
+ function knownTotal(sizes: Array<number | null>): number | null {
179
+ return sizes.some((size) => size === null) ? null : sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
180
+ }
181
+
182
+ function bestFitting(
183
+ groups: Array<Array<TwitterCandidate & { bytes: number | null }>>,
184
+ maxBytes: number,
185
+ ): TwitterCandidate[] | null {
186
+ let selections: Array<{ bytes: number; candidates: TwitterCandidate[]; quality: number }> = [
187
+ { bytes: 0, candidates: [], quality: 0 },
188
+ ];
189
+ for (const group of groups) {
190
+ selections = selections.flatMap((selection) =>
191
+ group.flatMap((candidate) => {
192
+ if (candidate.bytes === null || selection.bytes + candidate.bytes > maxBytes) {
193
+ return [];
194
+ }
195
+ return [{
196
+ bytes: selection.bytes + candidate.bytes,
197
+ candidates: [...selection.candidates, candidate],
198
+ quality: selection.quality + candidate.bitrate,
199
+ }];
200
+ }),
201
+ );
202
+ if (selections.length === 0) {
203
+ return null;
204
+ }
205
+ }
206
+ const best = selections.reduce((current, selection) => (selection.quality > current.quality ? selection : current));
207
+ return best.candidates;
208
+ }
209
+
210
+ async function contentLength(net: Net, item: MediaItem): Promise<number | null> {
211
+ try {
212
+ const response = await net(item.url, { headers: item.headers, method: "HEAD" }, 1);
213
+ const raw = response.ok ? response.headers.get("content-length") : null;
214
+ if (!raw || !/^\d+$/.test(raw)) {
215
+ return null;
216
+ }
217
+ const bytes = Number(raw);
218
+ return Number.isSafeInteger(bytes) ? bytes : null;
219
+ } catch {
220
+ return null;
221
+ }
103
222
  }
104
223
 
105
224
  export function tweetId(input: string): string | null {