@postfetch/core 0.9.0 → 0.9.1

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.
@@ -73,6 +73,7 @@ export declare function download(item: MediaItem, options?: DownloadOptions): Pr
73
73
  export declare function downloadBlob(url: string, options: DownloadBlobOptions & {
74
74
  remux: true;
75
75
  }): Promise<RemuxedVideo>;
76
+ /** Download without remuxing and return the upstream media Blob. */
76
77
  export declare function downloadBlob(url: string, options?: DownloadBlobOptions & {
77
78
  remux?: false;
78
79
  }): Promise<Blob>;
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;
@@ -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.1",
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
@@ -101,6 +101,7 @@ export async function downloadBlob(
101
101
  url: string,
102
102
  options: DownloadBlobOptions & { remux: true },
103
103
  ): Promise<RemuxedVideo>;
104
+ /** Download without remuxing and return the upstream media Blob. */
104
105
  export async function downloadBlob(
105
106
  url: string,
106
107
  options?: DownloadBlobOptions & { remux?: false },
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>();