@postfetch/core 0.5.2 → 0.7.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 +8 -4
- package/dist/download.d.ts +20 -0
- package/dist/fingerprint.d.ts +5 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +500 -59
- package/dist/internal.d.ts +13 -2
- package/dist/linkedin.d.ts +3 -0
- package/dist/postfetch.d.ts +11 -3
- package/package.json +3 -2
- package/src/download.ts +32 -0
- package/src/facebook.ts +27 -13
- package/src/fingerprint.ts +21 -7
- package/src/index.ts +12 -4
- package/src/instagram.ts +54 -22
- package/src/internal.ts +12 -1
- package/src/linkedin.ts +243 -0
- package/src/postfetch.ts +88 -7
- package/src/tiktok.ts +121 -16
- package/src/twitter.ts +33 -3
package/src/linkedin.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
count,
|
|
3
|
+
filename,
|
|
4
|
+
isoFromDateString,
|
|
5
|
+
object,
|
|
6
|
+
PostfetchError,
|
|
7
|
+
string,
|
|
8
|
+
type Json,
|
|
9
|
+
type MediaItem,
|
|
10
|
+
type PostMetadata,
|
|
11
|
+
type PostfetchResult,
|
|
12
|
+
type ResolveContext,
|
|
13
|
+
} from "./internal";
|
|
14
|
+
import { browserUserAgent, navigationHeaders } from "./fingerprint";
|
|
15
|
+
|
|
16
|
+
type LinkedinPostKind = "activity" | "share" | "ugcPost";
|
|
17
|
+
|
|
18
|
+
type LinkedinPostReference = {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: LinkedinPostKind;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function resolveLinkedin(input: ResolveContext): Promise<PostfetchResult> {
|
|
24
|
+
const reference = postReference(input.url);
|
|
25
|
+
const pageUrl = `https://www.linkedin.com/feed/update/urn:li:${reference.kind}:${reference.id}`;
|
|
26
|
+
const response = await input.net(pageUrl, { headers: navigationHeaders() });
|
|
27
|
+
if (response.status === 404) {
|
|
28
|
+
throw new PostfetchError(404, "LinkedIn post not found", "notFound");
|
|
29
|
+
}
|
|
30
|
+
if (response.status === 429 || response.status === 999) {
|
|
31
|
+
throw new PostfetchError(429, "LinkedIn rate limit reached", "rateLimited");
|
|
32
|
+
}
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new PostfetchError(response.status, "LinkedIn post unavailable", "unavailable");
|
|
35
|
+
}
|
|
36
|
+
if (response.url.includes("/signup/") || response.url.includes("/login")) {
|
|
37
|
+
throw new PostfetchError(401, "LinkedIn login required", "loginRequired");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const html = await response.text();
|
|
41
|
+
const post = linkedinPost(html);
|
|
42
|
+
if (!post) {
|
|
43
|
+
throw new PostfetchError(404, "LinkedIn post unavailable", "unavailable");
|
|
44
|
+
}
|
|
45
|
+
const canonical = string(post["@id"]);
|
|
46
|
+
const canonicalReference = canonical ? postReferenceOrNull(canonical) : null;
|
|
47
|
+
const id = canonicalReference?.id ?? reference.id;
|
|
48
|
+
const items = linkedinItems(post, html, id, pageUrl);
|
|
49
|
+
if (items.length === 0) {
|
|
50
|
+
throw new Error("LinkedIn media url not found");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
archiveFilename: filename(`linkedin_${id}.zip`),
|
|
54
|
+
id,
|
|
55
|
+
items,
|
|
56
|
+
metadata: linkedinMetadata(post),
|
|
57
|
+
platform: "linkedin",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function linkedinMetadata(post: Json): PostMetadata {
|
|
62
|
+
const author = linkedinAuthor(post);
|
|
63
|
+
return {
|
|
64
|
+
title: string(post.text) ?? string(post.name) ?? string(post.headline) ?? undefined,
|
|
65
|
+
text: string(post.description) ?? string(post.articleBody) ?? string(post.text) ?? undefined,
|
|
66
|
+
author: author
|
|
67
|
+
? {
|
|
68
|
+
handle: linkedinHandle(string(author.url)) ?? undefined,
|
|
69
|
+
name: string(author.name) ?? undefined,
|
|
70
|
+
}
|
|
71
|
+
: undefined,
|
|
72
|
+
createdAt: isoFromDateString(post.datePublished ?? post.uploadDate),
|
|
73
|
+
likeCount: interactionCount(post.interactionStatistic, "LikeAction"),
|
|
74
|
+
commentCount: count(post.commentCount) ?? interactionCount(post.interactionStatistic, "CommentAction"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function postReference(input: string): LinkedinPostReference {
|
|
79
|
+
const reference = postReferenceOrNull(input);
|
|
80
|
+
if (!reference) {
|
|
81
|
+
throw new PostfetchError(400, "LinkedIn post id not found");
|
|
82
|
+
}
|
|
83
|
+
return reference;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function postReferenceOrNull(input: string): LinkedinPostReference | null {
|
|
87
|
+
let pathname: string;
|
|
88
|
+
try {
|
|
89
|
+
pathname = new URL(input).pathname.replace(/%3A/gi, ":");
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const urn = pathname.match(/\/(?:embed\/)?feed\/update\/urn:li:(activity|share|ugcpost):(\d+)\/?$/i);
|
|
94
|
+
const post = pathname.match(/\/posts\/[^/]*-(activity|share|ugcpost)-(\d+)(?:-[^/]*)?\/?$/i);
|
|
95
|
+
const match = urn ?? post;
|
|
96
|
+
if (!match) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
switch (match[1].toLowerCase()) {
|
|
100
|
+
case "activity":
|
|
101
|
+
return { id: match[2], kind: "activity" };
|
|
102
|
+
case "share":
|
|
103
|
+
return { id: match[2], kind: "share" };
|
|
104
|
+
case "ugcpost":
|
|
105
|
+
return { id: match[2], kind: "ugcPost" };
|
|
106
|
+
default:
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function linkedinAuthor(post: Json): Json | null {
|
|
112
|
+
if (object(post.creator)) {
|
|
113
|
+
return post.creator;
|
|
114
|
+
}
|
|
115
|
+
if (object(post.author)) {
|
|
116
|
+
return post.author;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function linkedinPost(html: string): Json | null {
|
|
122
|
+
for (const match of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
123
|
+
if (!/\btype=["']application\/ld\+json["']/i.test(match[1])) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseJson(match[2]);
|
|
127
|
+
for (const entry of jsonLdEntries(parsed)) {
|
|
128
|
+
if (hasSchemaType(entry, "VideoObject") || hasSchemaType(entry, "SocialMediaPosting")) {
|
|
129
|
+
return entry;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseJson(value: string): unknown {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(value);
|
|
139
|
+
} catch {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(decodeHtml(value));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function jsonLdEntries(value: unknown): Json[] {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.filter(object);
|
|
151
|
+
}
|
|
152
|
+
if (!object(value)) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
return Array.isArray(value["@graph"]) ? value["@graph"].filter(object) : [value];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasSchemaType(value: Json, expected: string): boolean {
|
|
159
|
+
const type = value["@type"];
|
|
160
|
+
return type === expected || (Array.isArray(type) && type.includes(expected));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function linkedinItems(post: Json, html: string, id: string, referer: string): MediaItem[] {
|
|
164
|
+
const headers = { referer, "user-agent": browserUserAgent() };
|
|
165
|
+
if (hasSchemaType(post, "VideoObject")) {
|
|
166
|
+
const url = bestVideoUrl(html) ?? string(post.contentUrl);
|
|
167
|
+
return url
|
|
168
|
+
? [{ filename: filename(`linkedin_${id}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "linkedin", url }]
|
|
169
|
+
: [];
|
|
170
|
+
}
|
|
171
|
+
const url = imageUrl(post.image);
|
|
172
|
+
return url
|
|
173
|
+
? [{ filename: filename(`linkedin_${id}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "linkedin", url }]
|
|
174
|
+
: [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bestVideoUrl(html: string): string | null {
|
|
178
|
+
const encoded = html.match(/<video\b[^>]*\bdata-sources=(["'])(.*?)\1[^>]*>/is)?.[2];
|
|
179
|
+
if (!encoded) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(decodeHtml(encoded));
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const sources = Array.isArray(parsed) ? parsed.filter(object) : [];
|
|
189
|
+
const videos = sources.filter((source) => string(source.type) === "video/mp4" && string(source.src));
|
|
190
|
+
const selected = videos.reduce<Json | null>((best, candidate) => {
|
|
191
|
+
const bitrate = count(candidate["data-bitrate"]) ?? 0;
|
|
192
|
+
const bestBitrate = best ? count(best["data-bitrate"]) ?? 0 : -1;
|
|
193
|
+
return bitrate > bestBitrate ? candidate : best;
|
|
194
|
+
}, null);
|
|
195
|
+
return selected ? string(selected.src) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function imageUrl(value: unknown): string | null {
|
|
199
|
+
if (typeof value === "string") {
|
|
200
|
+
return string(value);
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value.map(imageUrl).find((url): url is string => Boolean(url)) ?? null;
|
|
204
|
+
}
|
|
205
|
+
return object(value) ? string(value.url) : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function interactionCount(value: unknown, action: string): number | undefined {
|
|
209
|
+
const statistics = Array.isArray(value) ? value.filter(object) : object(value) ? [value] : [];
|
|
210
|
+
const statistic = statistics.find((entry) => string(entry.interactionType)?.endsWith(`/${action}`));
|
|
211
|
+
return statistic ? count(statistic.userInteractionCount) : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function linkedinHandle(value: string | null): string | null {
|
|
215
|
+
if (!value) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
return new URL(value).pathname.match(/^\/(?:company|in|showcase)\/([^/]+)/)?.[1] ?? null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function decodeHtml(value: string): string {
|
|
226
|
+
return value.replace(/&(quot|amp|lt|gt|#39|#x27|#(\d+)|#x([0-9a-f]+));/gi, (_entity, name: string, decimal: string, hex: string) => {
|
|
227
|
+
switch (name.toLowerCase()) {
|
|
228
|
+
case "quot":
|
|
229
|
+
return '"';
|
|
230
|
+
case "amp":
|
|
231
|
+
return "&";
|
|
232
|
+
case "lt":
|
|
233
|
+
return "<";
|
|
234
|
+
case "gt":
|
|
235
|
+
return ">";
|
|
236
|
+
case "#39":
|
|
237
|
+
case "#x27":
|
|
238
|
+
return "'";
|
|
239
|
+
default:
|
|
240
|
+
return String.fromCodePoint(decimal ? Number.parseInt(decimal, 10) : Number.parseInt(hex, 16));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/postfetch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { asUrl, createNet, PostfetchError, type Platform, type PostfetchResult } from "./internal";
|
|
1
|
+
import { asUrl, createNet, PostfetchError, type MediaItem, type Platform, type PostfetchResult, type ResolveContext } from "./internal";
|
|
2
2
|
import { resolveFacebook } from "./facebook";
|
|
3
3
|
import { resolveInstagram } from "./instagram";
|
|
4
|
+
import { resolveLinkedin } from "./linkedin";
|
|
4
5
|
import { resolvePinterest } from "./pinterest";
|
|
5
6
|
import { resolveReddit } from "./reddit";
|
|
6
7
|
import { resolveSoundcloud } from "./soundcloud";
|
|
@@ -14,6 +15,13 @@ export type PostfetchOptions = {
|
|
|
14
15
|
fetch?: typeof fetch;
|
|
15
16
|
/** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
|
|
16
17
|
preferredWidth?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Soft file-size cap in bytes. The normal rendition is resolved and probed
|
|
20
|
+
* with HEAD; when it exceeds the cap, a smaller available rendition is
|
|
21
|
+
* returned. If size discovery or the smaller rendition is unavailable, the
|
|
22
|
+
* normal result is returned unchanged.
|
|
23
|
+
*/
|
|
24
|
+
tryMaxBytes?: number;
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
/**
|
|
@@ -21,10 +29,11 @@ export type PostfetchOptions = {
|
|
|
21
29
|
*
|
|
22
30
|
* Detects the platform from the URL host, then returns the post's media as typed
|
|
23
31
|
* {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
|
|
24
|
-
* performs no side effects beyond the lookup; use {@link download},
|
|
25
|
-
* or {@link toResponse} to turn the result
|
|
32
|
+
* performs no side effects beyond the lookup; use {@link download},
|
|
33
|
+
* {@link downloadBlob}, {@link archive} or {@link toResponse} to turn the result
|
|
34
|
+
* into bytes, a `Blob` or a `Response`.
|
|
26
35
|
*
|
|
27
|
-
* @param url A post URL from
|
|
36
|
+
* @param url A post URL from a supported platform.
|
|
28
37
|
* @param options See {@link PostfetchOptions}.
|
|
29
38
|
* @returns The resolved post and its media items.
|
|
30
39
|
* @throws {PostfetchError} If the URL is empty or its host is unsupported.
|
|
@@ -42,16 +51,26 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
|
|
|
42
51
|
if (trimmed.length === 0) {
|
|
43
52
|
throw new PostfetchError(400, "url is required");
|
|
44
53
|
}
|
|
45
|
-
|
|
54
|
+
if (options.tryMaxBytes !== undefined && (!Number.isSafeInteger(options.tryMaxBytes) || options.tryMaxBytes <= 0)) {
|
|
55
|
+
throw new PostfetchError(400, "tryMaxBytes must be a positive integer");
|
|
56
|
+
}
|
|
57
|
+
const context: ResolveContext = {
|
|
46
58
|
net: createNet(options.fetch ?? globalThis.fetch),
|
|
47
59
|
preferredWidth: options.preferredWidth ?? 720,
|
|
48
60
|
url: trimmed,
|
|
49
61
|
};
|
|
50
|
-
|
|
62
|
+
const result = await resolve(context);
|
|
63
|
+
return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolve(context: ResolveContext): Promise<PostfetchResult> {
|
|
67
|
+
switch (detect(context.url)) {
|
|
51
68
|
case "facebook":
|
|
52
69
|
return resolveFacebook(context);
|
|
53
70
|
case "instagram":
|
|
54
71
|
return resolveInstagram(context);
|
|
72
|
+
case "linkedin":
|
|
73
|
+
return resolveLinkedin(context);
|
|
55
74
|
case "pinterest":
|
|
56
75
|
return resolvePinterest(context);
|
|
57
76
|
case "reddit":
|
|
@@ -67,6 +86,65 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
|
|
|
67
86
|
}
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
async function trySmaller(context: ResolveContext, result: PostfetchResult, maxBytes: number): Promise<PostfetchResult> {
|
|
90
|
+
const originalBytes = await resultBytes(context, result);
|
|
91
|
+
if (originalBytes === null || originalBytes <= maxBytes) {
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
// Width-aware resolvers interpret 1px as "pick the smallest available".
|
|
96
|
+
// Resolvers without a smaller rendition return the same media unchanged.
|
|
97
|
+
const smaller = await resolve({ ...context, preferredWidth: 1 });
|
|
98
|
+
if (sameMedia(result, smaller)) {
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
const smallerBytes = await resultBytes(context, smaller);
|
|
102
|
+
return smallerBytes !== null && smallerBytes < originalBytes ? smaller : result;
|
|
103
|
+
} catch {
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function resultBytes(context: ResolveContext, result: PostfetchResult): Promise<number | null> {
|
|
109
|
+
const sizes = await Promise.all(result.items.map((item) => itemBytes(context, item)));
|
|
110
|
+
if (sizes.some((size) => size === null)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function itemBytes(context: ResolveContext, item: MediaItem): Promise<number | null> {
|
|
117
|
+
if (item.hls) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const [video, audio] = await Promise.all([
|
|
121
|
+
contentLength(context, item.url, item.headers),
|
|
122
|
+
item.audio ? contentLength(context, item.audio.url, item.audio.headers) : Promise.resolve(0),
|
|
123
|
+
]);
|
|
124
|
+
return video === null || audio === null ? null : video + audio;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function contentLength(context: ResolveContext, url: string, headers: HeadersInit): Promise<number | null> {
|
|
128
|
+
try {
|
|
129
|
+
const response = await context.net(url, { headers, method: "HEAD" }, 1);
|
|
130
|
+
const raw = response.ok ? response.headers.get("content-length") : null;
|
|
131
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const bytes = Number(raw);
|
|
135
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sameMedia(left: PostfetchResult, right: PostfetchResult): boolean {
|
|
142
|
+
return left.items.length === right.items.length && left.items.every((item, index) => {
|
|
143
|
+
const other = right.items[index];
|
|
144
|
+
return item.url === other?.url && item.audio?.url === other.audio?.url;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
70
148
|
/**
|
|
71
149
|
* Detect which platform a URL belongs to, from its host.
|
|
72
150
|
*
|
|
@@ -82,6 +160,9 @@ export function detect(url: string): Platform {
|
|
|
82
160
|
if (host.includes("instagram.com")) {
|
|
83
161
|
return "instagram";
|
|
84
162
|
}
|
|
163
|
+
if (host === "linkedin.com" || host.endsWith(".linkedin.com")) {
|
|
164
|
+
return "linkedin";
|
|
165
|
+
}
|
|
85
166
|
if (host.includes("pinterest.") || host === "pin.it") {
|
|
86
167
|
return "pinterest";
|
|
87
168
|
}
|
|
@@ -100,5 +181,5 @@ export function detect(url: string): Platform {
|
|
|
100
181
|
if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
|
|
101
182
|
return "twitter";
|
|
102
183
|
}
|
|
103
|
-
throw new PostfetchError(400, "only Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
184
|
+
throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
104
185
|
}
|
package/src/tiktok.ts
CHANGED
|
@@ -14,27 +14,26 @@ import {
|
|
|
14
14
|
type PostfetchResult,
|
|
15
15
|
type MediaItem,
|
|
16
16
|
} from "./internal";
|
|
17
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/twitter.ts
CHANGED
|
@@ -26,8 +26,10 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
26
26
|
if (string(tweet.__typename) === "TweetTombstone") {
|
|
27
27
|
throw new Error("Tweet is unavailable or age-restricted");
|
|
28
28
|
}
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const items = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
|
|
30
|
+
const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
|
|
31
|
+
return media.flatMap((entry, index) => twitterItem(entry, postId, index + 1));
|
|
32
|
+
});
|
|
31
33
|
if (items.length === 0) {
|
|
32
34
|
throw new Error("Twitter media not found");
|
|
33
35
|
}
|
|
@@ -36,6 +38,16 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
|
|
|
36
38
|
|
|
37
39
|
export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterExtra } {
|
|
38
40
|
const user = object(tweet.user) ? tweet.user : null;
|
|
41
|
+
const extra: TwitterExtra = {};
|
|
42
|
+
const lang = string(tweet.lang);
|
|
43
|
+
if (lang) {
|
|
44
|
+
extra.lang = lang;
|
|
45
|
+
}
|
|
46
|
+
const quotedTweet = object(tweet.quoted_tweet) ? tweet.quoted_tweet : null;
|
|
47
|
+
const quotedId = quotedTweet ? string(quotedTweet.id_str) : null;
|
|
48
|
+
if (quotedTweet && quotedId) {
|
|
49
|
+
extra.quotedTweet = { id: quotedId, metadata: twitterMetadata(quotedTweet) };
|
|
50
|
+
}
|
|
39
51
|
return {
|
|
40
52
|
text: (string(tweet.text) ?? string(tweet.full_text)) ?? undefined,
|
|
41
53
|
author: user
|
|
@@ -51,10 +63,28 @@ export function twitterMetadata(tweet: Json): PostMetadata & { extra?: TwitterEx
|
|
|
51
63
|
shareCount: count(tweet.retweet_count),
|
|
52
64
|
viewCount: count(tweet.view_count),
|
|
53
65
|
nsfw: bool(tweet.possibly_sensitive),
|
|
54
|
-
extra
|
|
66
|
+
extra,
|
|
55
67
|
};
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
function twitterTweets(tweet: Json, rootId: string): Array<{ id: string; tweet: Json }> {
|
|
71
|
+
const tweets: Array<{ id: string; tweet: Json }> = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
let current: Json | null = tweet;
|
|
74
|
+
let fallbackId: string | null = rootId;
|
|
75
|
+
while (current) {
|
|
76
|
+
const id = string(current.id_str) ?? fallbackId;
|
|
77
|
+
if (!id || seen.has(id)) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
tweets.push({ id, tweet: current });
|
|
81
|
+
seen.add(id);
|
|
82
|
+
current = object(current.quoted_tweet) ? current.quoted_tweet : null;
|
|
83
|
+
fallbackId = null;
|
|
84
|
+
}
|
|
85
|
+
return tweets;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
// The public syndication endpoint returns tweet media (video variants + photos)
|
|
59
89
|
// without a guest token or bearer; the token is a non-validated cache key.
|
|
60
90
|
async function syndication(net: Net, id: string): Promise<Json> {
|