@postfetch/core 0.5.1 → 0.6.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/tiktok.ts CHANGED
@@ -14,27 +14,26 @@ import {
14
14
  type PostfetchResult,
15
15
  type MediaItem,
16
16
  } from "./internal";
17
- import { browserUserAgent, firefoxUserAgent } from "./fingerprint";
17
+ import {
18
+ browserUserAgent,
19
+ firefoxNavigationHeaders,
20
+ navigationHeaders,
21
+ } from "./fingerprint";
18
22
 
19
23
  const marker = '<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">';
24
+ const pageAttemptCount = 12;
20
25
 
21
26
  export async function resolveTiktok(input: ResolveContext): Promise<PostfetchResult> {
22
- const userAgent = browserUserAgent();
23
- const pageUrl = await followShortlink(input.net, userAgent, input.url);
27
+ const pageUrl = await followShortlink(input.net, browserUserAgent(), input.url);
24
28
  const id = videoId(pageUrl);
25
29
  if (!id) {
26
30
  throw new Error("TikTok video id not found");
27
31
  }
28
- const page = await fetchVideoPage(input.net, id, userAgent).catch((error: unknown) => {
29
- if (!recoverablePageError(error)) {
30
- throw error;
31
- }
32
- return fetchVideoPage(input.net, id, firefoxUserAgent());
33
- });
32
+ const page = await videoPage(input.net, id, asUrl(pageUrl).pathname.includes("/photo/"));
34
33
  const user = author(page.item) ?? username(pageUrl) ?? "i";
35
34
  const headers: Record<string, string> = {
36
35
  referer: `https://www.tiktok.com/@${encodeURIComponent(user)}/video/${encodeURIComponent(id)}`,
37
- "user-agent": userAgent,
36
+ "user-agent": page.userAgent,
38
37
  };
39
38
  if (page.cookie) {
40
39
  headers.cookie = page.cookie;
@@ -46,6 +45,41 @@ export async function resolveTiktok(input: ResolveContext): Promise<PostfetchRes
46
45
  return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, metadata: tiktokMetadata(page.item), platform: "tiktok" };
47
46
  }
48
47
 
48
+ async function videoPage(
49
+ net: Net,
50
+ id: string,
51
+ photo: boolean,
52
+ ): Promise<{ item: Json; cookie: string | null; userAgent: string }> {
53
+ for (let attempt = 0; attempt < pageAttemptCount; attempt += 1) {
54
+ const baseHeaders = attempt % 2 === 0 ? navigationHeaders() : firefoxNavigationHeaders();
55
+ const userAgent = baseHeaders["user-agent"];
56
+ const headers = {
57
+ ...baseHeaders,
58
+ referer: "https://www.tiktok.com/",
59
+ "sec-fetch-site": "same-origin",
60
+ };
61
+ try {
62
+ return { ...(await fetchVideoPage(net, id, headers)), userAgent };
63
+ } catch (error) {
64
+ if (!recoverablePageError(error)) {
65
+ throw error;
66
+ }
67
+ }
68
+ }
69
+
70
+ if (!photo) {
71
+ throw new Error("TikTok hydration not found");
72
+ }
73
+ const baseHeaders = navigationHeaders();
74
+ const userAgent = baseHeaders["user-agent"];
75
+ const headers = {
76
+ ...baseHeaders,
77
+ referer: "https://www.tiktok.com/",
78
+ "sec-fetch-site": "same-origin",
79
+ };
80
+ return { ...(await fetchEmbedPage(net, id, headers)), userAgent };
81
+ }
82
+
49
83
  export function tiktokMetadata(item: Json): PostMetadata & { extra?: TiktokExtra } {
50
84
  const user = object(item.author) ? item.author : null;
51
85
  const stats = object(item.stats) ? item.stats : null;
@@ -78,18 +112,25 @@ export function tiktokMetadata(item: Json): PostMetadata & { extra?: TiktokExtra
78
112
  async function fetchVideoPage(
79
113
  net: Net,
80
114
  id: string,
81
- userAgent: string,
115
+ headers: Record<string, string>,
82
116
  ): Promise<{ item: Json; cookie: string | null }> {
83
117
  const page = await net(`https://www.tiktok.com/@i/video/${id}`, {
84
- headers: {
85
- accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
86
- referer: "https://www.tiktok.com/",
87
- "user-agent": userAgent,
88
- },
118
+ headers,
89
119
  });
90
120
  return { cookie: cookieHeader(page.headers), item: itemStruct(await page.text()) };
91
121
  }
92
122
 
123
+ async function fetchEmbedPage(
124
+ net: Net,
125
+ id: string,
126
+ headers: Record<string, string>,
127
+ ): Promise<{ item: Json; cookie: string | null }> {
128
+ const page = await net(`https://www.tiktok.com/embed/v2/${id}`, {
129
+ headers,
130
+ });
131
+ return { cookie: cookieHeader(page.headers), item: embedItemStruct(await page.text(), id) };
132
+ }
133
+
93
134
  export function isShortlinkHost(hostname: string): boolean {
94
135
  return hostname === "vm.tiktok.com" || hostname === "vt.tiktok.com";
95
136
  }
@@ -134,11 +175,75 @@ function itemStruct(html: string): Json {
134
175
  return item;
135
176
  }
136
177
 
178
+ function embedItemStruct(html: string, id: string): Json {
179
+ const raw = html.match(/<script\b[^>]*\bid=["']__FRONTITY_CONNECT_STATE__["'][^>]*>([\s\S]*?)<\/script>/i)?.[1];
180
+ if (!raw) {
181
+ throw new Error("TikTok embed state not found");
182
+ }
183
+ const parsed: unknown = JSON.parse(raw);
184
+ const source = object(parsed) && object(parsed.source) ? parsed.source : null;
185
+ const data = source && object(source.data) ? source.data : null;
186
+ const rawRoute = data ? data[`/embed/v2/${id}`] : null;
187
+ const route = object(rawRoute) ? rawRoute : null;
188
+ const videoData = route && object(route.videoData) ? route.videoData : null;
189
+ const item = videoData && object(videoData.itemInfos) ? videoData.itemInfos : null;
190
+ if (!videoData || !item) {
191
+ throw new Error("TikTok embed item not found");
192
+ }
193
+
194
+ const authorInfo = object(videoData.authorInfos) ? videoData.authorInfos : {};
195
+ const musicInfo = object(videoData.musicInfos) ? videoData.musicInfos : {};
196
+ const video = object(item.video) ? item.video : {};
197
+ const videoMeta = object(video.videoMeta) ? video.videoMeta : {};
198
+ const imagePost = object(videoData.imagePostInfo) ? videoData.imagePostInfo : null;
199
+ const displayImages = imagePost && Array.isArray(imagePost.displayImages) ? imagePost.displayImages.filter(object) : [];
200
+ if (displayImages.length === 0) {
201
+ throw new Error("TikTok video hydration not found");
202
+ }
203
+
204
+ return {
205
+ ...item,
206
+ author: {
207
+ nickname: string(authorInfo.nickName),
208
+ uniqueId: string(authorInfo.uniqueId),
209
+ verified: bool(authorInfo.verified),
210
+ },
211
+ desc: string(item.text),
212
+ imagePost: {
213
+ images: displayImages.map((image) => ({
214
+ imageURL: {
215
+ urlList: Array.isArray(image.urlList) ? image.urlList.map(string).filter((url): url is string => Boolean(url)) : [],
216
+ },
217
+ })),
218
+ },
219
+ music: {
220
+ authorName: string(musicInfo.authorName),
221
+ playUrl: firstString(musicInfo.playUrl),
222
+ title: string(musicInfo.musicName),
223
+ },
224
+ stats: {
225
+ commentCount: item.commentCount,
226
+ diggCount: item.diggCount,
227
+ playCount: item.playCount,
228
+ shareCount: item.shareCount,
229
+ },
230
+ video: {
231
+ duration: videoMeta.duration,
232
+ },
233
+ };
234
+ }
235
+
137
236
  function recoverablePageError(error: unknown): boolean {
138
237
  const message = error instanceof Error ? error.message : "";
139
238
  return message === "TikTok WAF challenge" || message === "TikTok hydration not found";
140
239
  }
141
240
 
241
+ function firstString(value: unknown): string | null {
242
+ return Array.isArray(value)
243
+ ? value.map(string).find((candidate): candidate is string => candidate !== null) ?? null
244
+ : string(value);
245
+ }
246
+
142
247
  function downloadUrl(item: Json): string | null {
143
248
  const video = object(item.video) ? item.video : null;
144
249
  return video ? string(video.playAddr) ?? string(video.downloadAddr) : null;