@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 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(url, options?)` materializes an already-resolved media URL for
17
- clients that need an uploadable `Blob`. Pass the resolved item's headers for
18
- protected CDN URLs: `downloadBlob(item.url, { headers: item.headers })`. Set
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
@@ -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 an already-resolved media URL as a Blob.
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. When the resolved {@link MediaItem} carries
57
- * required CDN headers, pass them in the options object. Set `remux: true` to
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 url A direct media URL returned by a resolver.
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.url, { headers: media.headers, remux: true });
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(url: string, options: DownloadBlobOptions & {
74
+ export declare function downloadBlob(input: string | MediaItem, options: DownloadBlobOptions & {
74
75
  remux: true;
75
76
  }): Promise<RemuxedVideo>;
76
- export declare function downloadBlob(url: string, options?: DownloadBlobOptions & {
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: string(tweet.text) ?? string(tweet.full_text) ?? undefined,
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(url, optionsOrHeaders = {}, legacyOptions = {}) {
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(url, { headers });
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
  }
@@ -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 platform did not expose it in the payload already fetched to
51
- * resolve the media (never a fabricated default — a missing count is `undefined`,
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.0",
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 an already-resolved media URL as a Blob.
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. When the resolved {@link MediaItem} carries
84
- * required CDN headers, pass them in the options object. Set `remux: true` to
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 url A direct media URL returned by a resolver.
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.url, { headers: media.headers, remux: true });
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
- url: string,
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
- url: string,
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
- url: string,
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 = await net(url, { headers });
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 platform did not expose it in the payload already fetched to
52
- * resolve the media (never a fabricated default — a missing count is `undefined`,
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: (string(tweet.text) ?? string(tweet.full_text)) ?? undefined,
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>();