@postfetch/core 0.9.0 → 0.9.2
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 +5 -3
- package/dist/download.d.ts +9 -7
- package/dist/index.js +55 -3
- package/dist/internal.d.ts +2 -2
- package/package.json +1 -1
- package/src/download.ts +13 -9
- package/src/internal.ts +2 -2
- package/src/twitter.ts +63 -1
package/README.md
CHANGED
|
@@ -13,9 +13,11 @@ for (const item of result.items) {
|
|
|
13
13
|
|
|
14
14
|
`postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `downloadBlob`, `archive` and `toResponse` materialize the result.
|
|
15
15
|
|
|
16
|
-
`downloadBlob(
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
`downloadBlob(itemOrUrl, options?)` materializes resolved media for clients that
|
|
17
|
+
need an uploadable `Blob`. Prefer `downloadBlob(item, { remux: true })` to
|
|
18
|
+
include separate audio, assemble HLS playlists and use the item's headers.
|
|
19
|
+
Passing only `item.url` fetches that URL alone and may omit separate audio;
|
|
20
|
+
for direct URLs, pass `{ headers: item.headers }` when needed. Set
|
|
19
21
|
`remux: true` to return `{ blob, thumbnail, width, height, duration }`: a
|
|
20
22
|
normalized MP4, upload thumbnail and calculated presentation metadata. Remuxing
|
|
21
23
|
defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
|
package/dist/download.d.ts
CHANGED
|
@@ -50,30 +50,32 @@ export type Archive = {
|
|
|
50
50
|
*/
|
|
51
51
|
export declare function download(item: MediaItem, options?: DownloadOptions): Promise<Response>;
|
|
52
52
|
/**
|
|
53
|
-
* Download
|
|
53
|
+
* Download a resolved media item or direct media URL as a Blob.
|
|
54
54
|
*
|
|
55
55
|
* This is useful for consumers that need to upload media themselves instead of
|
|
56
|
-
* passing its URL to a third party.
|
|
57
|
-
*
|
|
56
|
+
* passing its URL to a third party. Pass the full {@link MediaItem} to include
|
|
57
|
+
* separate audio, HLS playlists and required headers. A direct URL only fetches
|
|
58
|
+
* that URL; pass its required CDN headers in the options object. Set `remux: true` to
|
|
58
59
|
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
59
60
|
* remux or metadata extraction fails, the operation throws.
|
|
60
61
|
*
|
|
61
|
-
* @param
|
|
62
|
+
* @param input A resolved media item (recommended) or direct media URL.
|
|
62
63
|
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
63
64
|
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
64
65
|
*
|
|
65
66
|
* @example Upload a video with FormData
|
|
66
67
|
* ```ts
|
|
67
68
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
68
|
-
* const video = await downloadBlob(media
|
|
69
|
+
* const video = await downloadBlob(media, { remux: true });
|
|
69
70
|
* form.append("video", video.blob, media.filename);
|
|
70
71
|
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
71
72
|
* ```
|
|
72
73
|
*/
|
|
73
|
-
export declare function downloadBlob(
|
|
74
|
+
export declare function downloadBlob(input: string | MediaItem, options: DownloadBlobOptions & {
|
|
74
75
|
remux: true;
|
|
75
76
|
}): Promise<RemuxedVideo>;
|
|
76
|
-
|
|
77
|
+
/** Download without remuxing and return the upstream media Blob. */
|
|
78
|
+
export declare function downloadBlob(input: string | MediaItem, options?: DownloadBlobOptions & {
|
|
77
79
|
remux?: false;
|
|
78
80
|
}): Promise<Blob>;
|
|
79
81
|
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
package/dist/index.js
CHANGED
|
@@ -2009,6 +2009,7 @@ async function resolveTwitter(input) {
|
|
|
2009
2009
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
2010
2010
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
2011
2011
|
}
|
|
2012
|
+
await expandTwitterText(input.net, tweet, id);
|
|
2012
2013
|
const groups = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
|
|
2013
2014
|
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
2014
2015
|
return media.flatMap((entry, index) => {
|
|
@@ -2032,7 +2033,7 @@ function twitterMetadata(tweet) {
|
|
|
2032
2033
|
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
2033
2034
|
}
|
|
2034
2035
|
return {
|
|
2035
|
-
text:
|
|
2036
|
+
text: twitterText(tweet) ?? undefined,
|
|
2036
2037
|
author: user ? {
|
|
2037
2038
|
handle: string(user.screen_name) ?? undefined,
|
|
2038
2039
|
name: string(user.name) ?? undefined,
|
|
@@ -2047,6 +2048,57 @@ function twitterMetadata(tweet) {
|
|
|
2047
2048
|
extra
|
|
2048
2049
|
};
|
|
2049
2050
|
}
|
|
2051
|
+
function noteText(tweet) {
|
|
2052
|
+
const note = object(tweet.note_tweet) ? tweet.note_tweet : null;
|
|
2053
|
+
const results = note && object(note.note_tweet_results) ? note.note_tweet_results : null;
|
|
2054
|
+
const result = results && object(results.result) ? results.result : null;
|
|
2055
|
+
return (result ? string(result.text) : null) ?? (note ? string(note.text) : null);
|
|
2056
|
+
}
|
|
2057
|
+
function twitterText(tweet) {
|
|
2058
|
+
return noteText(tweet) ?? string(tweet.full_text) ?? string(tweet.text);
|
|
2059
|
+
}
|
|
2060
|
+
function needsFullText(tweet) {
|
|
2061
|
+
if (noteText(tweet) || string(tweet.full_text)) {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
return object(tweet.note_tweet) || bool(tweet.truncated) === true || (string(tweet.text)?.length ?? 0) >= 270;
|
|
2065
|
+
}
|
|
2066
|
+
async function expandTwitterText(net, tweet, rootId) {
|
|
2067
|
+
const posts = new Map(twitterTweets(tweet, rootId).map(({ id, tweet: post }) => [id, post]));
|
|
2068
|
+
for (const [id, post] of posts) {
|
|
2069
|
+
if (!needsFullText(post)) {
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
try {
|
|
2073
|
+
const response = await net(`https://api.fxtwitter.com/status/${id}`, {
|
|
2074
|
+
headers: { accept: "application/json", "user-agent": browserUserAgent() }
|
|
2075
|
+
}, 1);
|
|
2076
|
+
if (!response.ok) {
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const payload = await response.json();
|
|
2080
|
+
if (!object(payload) || payload.code !== 200 || !object(payload.tweet) || string(payload.tweet.id) !== id) {
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
let current = payload.tweet;
|
|
2084
|
+
const seen = new Set;
|
|
2085
|
+
while (current) {
|
|
2086
|
+
const currentId = string(current.id);
|
|
2087
|
+
if (!currentId || seen.has(currentId)) {
|
|
2088
|
+
break;
|
|
2089
|
+
}
|
|
2090
|
+
seen.add(currentId);
|
|
2091
|
+
const target = posts.get(currentId);
|
|
2092
|
+
const raw = object(current.raw_text) ? string(current.raw_text.text) : null;
|
|
2093
|
+
const text = raw ?? string(current.text);
|
|
2094
|
+
if (target && needsFullText(target) && text && text.length > (twitterText(target)?.length ?? 0)) {
|
|
2095
|
+
target.full_text = text;
|
|
2096
|
+
}
|
|
2097
|
+
current = object(current.quote) ? current.quote : null;
|
|
2098
|
+
}
|
|
2099
|
+
} catch {}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2050
2102
|
function twitterTweets(tweet, rootId) {
|
|
2051
2103
|
const tweets = [];
|
|
2052
2104
|
const seen = new Set;
|
|
@@ -2895,12 +2947,12 @@ async function download(item, options = {}) {
|
|
|
2895
2947
|
}
|
|
2896
2948
|
return response;
|
|
2897
2949
|
}
|
|
2898
|
-
async function downloadBlob(
|
|
2950
|
+
async function downloadBlob(input, optionsOrHeaders = {}, legacyOptions = {}) {
|
|
2899
2951
|
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
2900
2952
|
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
2901
2953
|
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
2902
2954
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
2903
|
-
const response = await net(
|
|
2955
|
+
const response = typeof input === "string" ? await net(input, { headers }) : await download({ ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers }, options);
|
|
2904
2956
|
if (!response.ok || !response.body) {
|
|
2905
2957
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
2906
2958
|
}
|
package/dist/internal.d.ts
CHANGED
|
@@ -47,8 +47,8 @@ export type PostAuthor = {
|
|
|
47
47
|
};
|
|
48
48
|
/**
|
|
49
49
|
* Post metadata normalized across platforms. Every field is optional: an absent
|
|
50
|
-
* field means the
|
|
51
|
-
*
|
|
50
|
+
* field means the sources consulted did not expose it (never a fabricated
|
|
51
|
+
* default — a missing count is `undefined`,
|
|
52
52
|
* not `0`). All counts are non-negative. Platform-specific fields live in `extra`.
|
|
53
53
|
*/
|
|
54
54
|
export type PostMetadata = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/download.ts
CHANGED
|
@@ -77,38 +77,40 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Download
|
|
80
|
+
* Download a resolved media item or direct media URL as a Blob.
|
|
81
81
|
*
|
|
82
82
|
* This is useful for consumers that need to upload media themselves instead of
|
|
83
|
-
* passing its URL to a third party.
|
|
84
|
-
*
|
|
83
|
+
* passing its URL to a third party. Pass the full {@link MediaItem} to include
|
|
84
|
+
* separate audio, HLS playlists and required headers. A direct URL only fetches
|
|
85
|
+
* that URL; pass its required CDN headers in the options object. Set `remux: true` to
|
|
85
86
|
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
86
87
|
* remux or metadata extraction fails, the operation throws.
|
|
87
88
|
*
|
|
88
|
-
* @param
|
|
89
|
+
* @param input A resolved media item (recommended) or direct media URL.
|
|
89
90
|
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
90
91
|
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
91
92
|
*
|
|
92
93
|
* @example Upload a video with FormData
|
|
93
94
|
* ```ts
|
|
94
95
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
95
|
-
* const video = await downloadBlob(media
|
|
96
|
+
* const video = await downloadBlob(media, { remux: true });
|
|
96
97
|
* form.append("video", video.blob, media.filename);
|
|
97
98
|
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
98
99
|
* ```
|
|
99
100
|
*/
|
|
100
101
|
export async function downloadBlob(
|
|
101
|
-
|
|
102
|
+
input: string | MediaItem,
|
|
102
103
|
options: DownloadBlobOptions & { remux: true },
|
|
103
104
|
): Promise<RemuxedVideo>;
|
|
105
|
+
/** Download without remuxing and return the upstream media Blob. */
|
|
104
106
|
export async function downloadBlob(
|
|
105
|
-
|
|
107
|
+
input: string | MediaItem,
|
|
106
108
|
options?: DownloadBlobOptions & { remux?: false },
|
|
107
109
|
): Promise<Blob>;
|
|
108
110
|
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
109
111
|
export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
110
112
|
export async function downloadBlob(
|
|
111
|
-
|
|
113
|
+
input: string | MediaItem,
|
|
112
114
|
optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
|
|
113
115
|
legacyOptions: DownloadOptions = {},
|
|
114
116
|
): Promise<Blob | RemuxedVideo> {
|
|
@@ -116,7 +118,9 @@ export async function downloadBlob(
|
|
|
116
118
|
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
117
119
|
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
118
120
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
119
|
-
const response =
|
|
121
|
+
const response = typeof input === "string"
|
|
122
|
+
? await net(input, { headers })
|
|
123
|
+
: await download({ ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers }, options);
|
|
120
124
|
if (!response.ok || !response.body) {
|
|
121
125
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
122
126
|
}
|
package/src/internal.ts
CHANGED
|
@@ -48,8 +48,8 @@ export type PostAuthor = {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Post metadata normalized across platforms. Every field is optional: an absent
|
|
51
|
-
* field means the
|
|
52
|
-
*
|
|
51
|
+
* field means the sources consulted did not expose it (never a fabricated
|
|
52
|
+
* default — a missing count is `undefined`,
|
|
53
53
|
* not `0`). All counts are non-negative. Platform-specific fields live in `extra`.
|
|
54
54
|
*/
|
|
55
55
|
export type PostMetadata = {
|
package/src/twitter.ts
CHANGED
|
@@ -31,6 +31,7 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
31
31
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
32
32
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
33
33
|
}
|
|
34
|
+
await expandTwitterText(input.net, tweet, id);
|
|
34
35
|
const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
|
|
35
36
|
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
36
37
|
return media.flatMap((entry, index) => {
|
|
@@ -58,7 +59,7 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
58
59
|
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
59
60
|
}
|
|
60
61
|
return {
|
|
61
|
-
text: (
|
|
62
|
+
text: twitterText(tweet) ?? undefined,
|
|
62
63
|
author: user
|
|
63
64
|
? {
|
|
64
65
|
handle: string(user.screen_name) ?? undefined,
|
|
@@ -76,6 +77,67 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function noteText(tweet: Json): string | null {
|
|
81
|
+
const note = object(tweet.note_tweet) ? tweet.note_tweet : null;
|
|
82
|
+
const results = note && object(note.note_tweet_results) ? note.note_tweet_results : null;
|
|
83
|
+
const result = results && object(results.result) ? results.result : null;
|
|
84
|
+
return (result ? string(result.text) : null) ?? (note ? string(note.text) : null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function twitterText(tweet: Json): string | null {
|
|
88
|
+
return noteText(tweet) ?? string(tweet.full_text) ?? string(tweet.text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function needsFullText(tweet: Json): boolean {
|
|
92
|
+
if (noteText(tweet) || string(tweet.full_text)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
// Embedded quotes can omit note_tweet entirely. Near-limit text is only a
|
|
96
|
+
// hint: ordinary long tweets may also incur a lookup, but are never shortened.
|
|
97
|
+
return object(tweet.note_tweet) || bool(tweet.truncated) === true || (string(tweet.text)?.length ?? 0) >= 270;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function expandTwitterText(net: Net, tweet: Json, rootId: string): Promise<void> {
|
|
101
|
+
const posts = new Map(twitterTweets(tweet, rootId).map(({ id, tweet: post }) => [id, post]));
|
|
102
|
+
for (const [id, post] of posts) {
|
|
103
|
+
if (!needsFullText(post)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
// FxTwitter's public v1 API includes full note text and embedded quotes.
|
|
108
|
+
// Keep media and other metadata from X, and don't retry an optional lookup.
|
|
109
|
+
const response = await net(`https://api.fxtwitter.com/status/${id}`, {
|
|
110
|
+
headers: { accept: "application/json", "user-agent": browserUserAgent() },
|
|
111
|
+
}, 1);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const payload: unknown = await response.json();
|
|
116
|
+
if (!object(payload) || payload.code !== 200 || !object(payload.tweet) || string(payload.tweet.id) !== id) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let current: Json | null = payload.tweet;
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
while (current) {
|
|
122
|
+
const currentId = string(current.id);
|
|
123
|
+
if (!currentId || seen.has(currentId)) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
seen.add(currentId);
|
|
127
|
+
const target = posts.get(currentId);
|
|
128
|
+
const raw = object(current.raw_text) ? string(current.raw_text.text) : null;
|
|
129
|
+
const text = raw ?? string(current.text);
|
|
130
|
+
if (target && needsFullText(target) && text && text.length > (twitterText(target)?.length ?? 0)) {
|
|
131
|
+
target.full_text = text;
|
|
132
|
+
}
|
|
133
|
+
current = object(current.quote) ? current.quote : null;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// A text enrichment outage must not discard a valid syndication result.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
79
141
|
function twitterTweets(tweet: Json, rootId: string): Array<{ id: string; tweet: Json }> {
|
|
80
142
|
const tweets: Array<{ id: string; tweet: Json }> = [];
|
|
81
143
|
const seen = new Set<string>();
|