@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/README.md +13 -4
- package/dist/download.d.ts +32 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +381 -39
- package/dist/internal.d.ts +13 -0
- package/dist/mp4-remux.d.ts +18 -0
- package/dist/postfetch.d.ts +12 -2
- package/package.json +1 -1
- package/src/download.ts +68 -0
- package/src/facebook.ts +120 -19
- package/src/index.ts +12 -3
- package/src/internal.ts +12 -0
- package/src/mp4-remux.ts +100 -0
- package/src/postfetch.ts +85 -5
- package/src/twitter.ts +140 -21
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 {
|