@sprid/scout 0.1.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/LICENSE +14 -0
- package/README.md +115 -0
- package/package.json +32 -0
- package/src/ads/cli.mjs +82 -0
- package/src/ads/extract.mjs +108 -0
- package/src/ads/harvest.mjs +90 -0
- package/src/ads/push.mjs +56 -0
- package/src/ads/run.js +67 -0
- package/src/analyze.js +155 -0
- package/src/chrome.js +396 -0
- package/src/config.js +83 -0
- package/src/discover.js +463 -0
- package/src/download.js +324 -0
- package/src/frames.js +85 -0
- package/src/jobs.js +370 -0
- package/src/login-cli.js +30 -0
- package/src/push-to-sprid.js +194 -0
- package/src/quick.js +318 -0
- package/src/register.js +27 -0
- package/src/server.js +881 -0
- package/src/sheet.js +106 -0
- package/src/store.js +130 -0
- package/src/tiktok.js +391 -0
package/src/download.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { acquire, handleChallenge, invoke } = require("./chrome");
|
|
6
|
+
const { extractItem } = require("./tiktok");
|
|
7
|
+
const { sheetFromFrames, ffmpegAvailable, DEFAULT_OFFSETS } = require("./sheet");
|
|
8
|
+
const { capturePlayer, writeFrames } = require("./frames");
|
|
9
|
+
const { readProfilePosts } = require("./analyze");
|
|
10
|
+
const { DOWNLOADS_DIR } = require("./config");
|
|
11
|
+
const jobsLib = require("./jobs");
|
|
12
|
+
|
|
13
|
+
const DOWNLOAD_DEFAULTS = { count: 20, sort: "views", maxDays: 0 };
|
|
14
|
+
const MAX_DOWNLOAD_COUNT = 200;
|
|
15
|
+
|
|
16
|
+
const SORTS = {
|
|
17
|
+
views: (p) => p.stats?.playCount ?? 0,
|
|
18
|
+
likes: (p) => p.stats?.diggCount ?? 0,
|
|
19
|
+
comments: (p) => p.stats?.commentCount ?? 0,
|
|
20
|
+
shares: (p) => p.stats?.shareCount ?? 0,
|
|
21
|
+
favorites: (p) => p.stats?.collectCount ?? 0,
|
|
22
|
+
engagement: (p) => {
|
|
23
|
+
const s = p.stats ?? {};
|
|
24
|
+
const views = s.playCount ?? 0;
|
|
25
|
+
if (!views) return 0;
|
|
26
|
+
return (
|
|
27
|
+
((s.diggCount ?? 0) + (s.commentCount ?? 0) + (s.shareCount ?? 0) + (s.collectCount ?? 0)) /
|
|
28
|
+
views
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const SORT_VALUES = Object.keys(SORTS);
|
|
34
|
+
|
|
35
|
+
function slugify(value) {
|
|
36
|
+
return String(value)
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
39
|
+
.replace(/^-+|-+$/g, "")
|
|
40
|
+
.slice(0, 60);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function humanAge(seconds) {
|
|
44
|
+
const days = Math.trunc(seconds / 86400);
|
|
45
|
+
if (days < 1) return "today";
|
|
46
|
+
if (days < 30) return `${days}d ago`;
|
|
47
|
+
if (days < 365) return `${Math.trunc(days / 30)}mo ago`;
|
|
48
|
+
return `${Math.trunc(days / 365)}y ago`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read one post. The stashed SSR blob answers first; the XHR is the fallback. */
|
|
52
|
+
async function fetchItem(page, url, log, challenge) {
|
|
53
|
+
let captured = null;
|
|
54
|
+
const onResponse = async (response) => {
|
|
55
|
+
if (captured) return;
|
|
56
|
+
const responseUrl = response.url();
|
|
57
|
+
if (!responseUrl.includes("/api/item/detail") && !responseUrl.includes("/aweme/detail")) return;
|
|
58
|
+
try {
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
const item = data.itemInfo?.itemStruct ?? data.aweme_detail;
|
|
61
|
+
if (item && typeof item === "object") captured = item;
|
|
62
|
+
} catch {}
|
|
63
|
+
};
|
|
64
|
+
page.on("response", onResponse);
|
|
65
|
+
try {
|
|
66
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
67
|
+
await page.waitForTimeout(2500);
|
|
68
|
+
if ((await handleChallenge(page, log, challenge)) === "resumed") {
|
|
69
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
70
|
+
await page.waitForTimeout(2500);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
log(` Warning: could not load page: ${String(error).slice(0, 100)}`, true);
|
|
74
|
+
}
|
|
75
|
+
const stashed = await page
|
|
76
|
+
.evaluate(
|
|
77
|
+
invoke(
|
|
78
|
+
`() => window.__SS_REHYDRATION__ || (document.getElementById('__UNIVERSAL_DATA_FOR_REHYDRATION__') || {}).textContent || null`
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
.catch(() => null);
|
|
82
|
+
page.off("response", onResponse);
|
|
83
|
+
return extractItem(stashed) ?? captured;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeSlideshow(post, dir, position, log) {
|
|
87
|
+
const images = post.imagePost?.images ?? [];
|
|
88
|
+
if (images.length === 0) {
|
|
89
|
+
log(" This post is not a slideshow (no images found).", true);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const username = post.author?.uniqueId || "unknown";
|
|
93
|
+
const folder = `${String(position).padStart(2, "0")}_${slugify(username)}_${slugify(post.id)}`;
|
|
94
|
+
const postDir = path.join(dir, folder);
|
|
95
|
+
fs.mkdirSync(postDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
let slides = 0;
|
|
98
|
+
for (const [index, image] of images.entries()) {
|
|
99
|
+
const source = image.imageURL?.urlList?.[0];
|
|
100
|
+
if (!source) continue;
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(source, { headers: { Referer: "https://www.tiktok.com/" } });
|
|
103
|
+
if (!response.ok) continue;
|
|
104
|
+
const extension = response.headers.get("content-type")?.includes("webp") ? "webp" : "jpg";
|
|
105
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(postDir, `slide_${String(index + 1).padStart(2, "0")}.${extension}`),
|
|
108
|
+
buffer
|
|
109
|
+
);
|
|
110
|
+
slides += 1;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
log(` Warning: slide ${index + 1} failed: ${String(error).slice(0, 80)}`, true);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stats = post.stats ?? {};
|
|
117
|
+
const createTime = post.createTime ?? 0;
|
|
118
|
+
const elapsed = Date.now() / 1000 - createTime;
|
|
119
|
+
const views = stats.playCount ?? 0;
|
|
120
|
+
const interactions =
|
|
121
|
+
(stats.diggCount ?? 0) + (stats.commentCount ?? 0) + (stats.shareCount ?? 0) + (stats.collectCount ?? 0);
|
|
122
|
+
|
|
123
|
+
// Everything a ranking this tool does not offer would need, written beside
|
|
124
|
+
// the images so it can be worked out from what came down.
|
|
125
|
+
const metadata = {
|
|
126
|
+
id: post.id,
|
|
127
|
+
url: `https://www.tiktok.com/@${username}/photo/${post.id}`,
|
|
128
|
+
description: post.desc ?? "",
|
|
129
|
+
hashtags: (post.textExtra ?? []).map((t) => t.hashtagName).filter(Boolean),
|
|
130
|
+
views,
|
|
131
|
+
likes: stats.diggCount ?? 0,
|
|
132
|
+
comments: stats.commentCount ?? 0,
|
|
133
|
+
shares: stats.shareCount ?? 0,
|
|
134
|
+
favorites: stats.collectCount ?? 0,
|
|
135
|
+
engagement_rate: views ? Math.round((interactions / views) * 100 * 1e4) / 1e4 : 0,
|
|
136
|
+
slide_count: slides,
|
|
137
|
+
published_at: createTime ? new Date(createTime * 1000).toISOString() : null,
|
|
138
|
+
days_since_published: Math.trunc(elapsed / 86400),
|
|
139
|
+
age: createTime ? humanAge(elapsed) : null,
|
|
140
|
+
author: {
|
|
141
|
+
username,
|
|
142
|
+
display_name: post.author?.nickname,
|
|
143
|
+
verified: post.author?.verified ?? false,
|
|
144
|
+
},
|
|
145
|
+
music: { title: post.music?.title, author: post.music?.authorName },
|
|
146
|
+
};
|
|
147
|
+
fs.writeFileSync(path.join(postDir, "metadata.json"), JSON.stringify(metadata, null, 2));
|
|
148
|
+
log(` Saved ${folder} - ${slides} slides`);
|
|
149
|
+
return { folder, slides, metadata };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A video, read as frames off its own player.
|
|
154
|
+
*
|
|
155
|
+
* Not downloaded: the signed playable url answers 403 from node and from inside
|
|
156
|
+
* the signed-in page alike, because the player runs on Media Source Extensions
|
|
157
|
+
* and its src is a blob handle with nothing to fetch behind it. Seeking the
|
|
158
|
+
* player and drawing to a canvas gets the pixels, costs nothing extra on a long
|
|
159
|
+
* video, and leaves no copy of someone's file on disk - which is the right
|
|
160
|
+
* shape for evidence anyway.
|
|
161
|
+
*/
|
|
162
|
+
async function writeVideoFrames(post, page, dir, position, log, { offsets } = {}) {
|
|
163
|
+
const username = post.author?.uniqueId || "unknown";
|
|
164
|
+
const url = `https://www.tiktok.com/@${username}/video/${post.id}`;
|
|
165
|
+
const folder = `${String(position).padStart(2, "0")}_${slugify(username)}_${slugify(post.id)}`;
|
|
166
|
+
const postDir = path.join(dir, folder);
|
|
167
|
+
|
|
168
|
+
let captured;
|
|
169
|
+
try {
|
|
170
|
+
if (!page.url().includes(post.id)) {
|
|
171
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
|
|
172
|
+
await page.waitForTimeout(2500);
|
|
173
|
+
}
|
|
174
|
+
captured = await capturePlayer((source) => page.evaluate(source), {
|
|
175
|
+
offsets: offsets ?? DEFAULT_OFFSETS,
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
log(` Warning: could not read the player: ${String(error.message).slice(0, 120)}`, true);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (!captured.shots?.length) {
|
|
182
|
+
log(" The player gave no frames.", true);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const frames = writeFrames(captured.shots, postDir);
|
|
187
|
+
let sheet = null;
|
|
188
|
+
if (await ffmpegAvailable()) {
|
|
189
|
+
try {
|
|
190
|
+
sheet = await sheetFromFrames(frames, path.join(postDir, "sheet.png"));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
log(` Warning: no sheet: ${String(error.message).slice(0, 120)}`, true);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
log(" ffmpeg is not installed, so the frames are there but not tiled into a sheet.", true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const stats = post.stats ?? {};
|
|
199
|
+
const metadata = {
|
|
200
|
+
id: post.id,
|
|
201
|
+
type: "video",
|
|
202
|
+
url,
|
|
203
|
+
description: post.desc ?? "",
|
|
204
|
+
hashtags: (post.textExtra ?? []).map((t) => t.hashtagName).filter(Boolean),
|
|
205
|
+
duration_seconds: captured.duration ?? post.video?.duration ?? null,
|
|
206
|
+
frames_at_seconds: frames.map((f) => f.at),
|
|
207
|
+
views: stats.playCount ?? 0,
|
|
208
|
+
likes: stats.diggCount ?? 0,
|
|
209
|
+
comments: stats.commentCount ?? 0,
|
|
210
|
+
shares: stats.shareCount ?? 0,
|
|
211
|
+
favorites: stats.collectCount ?? 0,
|
|
212
|
+
published_at: post.createTime ? new Date(post.createTime * 1000).toISOString() : null,
|
|
213
|
+
sheet: sheet ? "sheet.png" : null,
|
|
214
|
+
};
|
|
215
|
+
fs.writeFileSync(path.join(postDir, "metadata.json"), JSON.stringify(metadata, null, 2));
|
|
216
|
+
log(` ${frames.length} frames${sheet ? " and a sheet" : ""}.`);
|
|
217
|
+
return { folder, slides: 0, metadata, sheet: sheet ? { offsets: sheet.offsets } : null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runDownload(job) {
|
|
221
|
+
const { accounts, links, count, sort, maxDays, includeVideos, sheetOffsets } = job.meta;
|
|
222
|
+
const log = (text, alert) => jobsLib.log(job, text, alert);
|
|
223
|
+
const gate = jobsLib.gate(job);
|
|
224
|
+
const runDir = path.join(DOWNLOADS_DIR, job.id);
|
|
225
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
const session = await acquire();
|
|
228
|
+
const challenge = {
|
|
229
|
+
hold: gate,
|
|
230
|
+
signal: session.challengeSignal,
|
|
231
|
+
onDetected: (prompt) => jobsLib.setAlert(job, "captcha", prompt),
|
|
232
|
+
};
|
|
233
|
+
const saved = [];
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
if (links?.length) {
|
|
237
|
+
for (const [index, url] of links.entries()) {
|
|
238
|
+
if (!(await gate())) break;
|
|
239
|
+
log(`\n[${index + 1}/${links.length}] ${url}`);
|
|
240
|
+
const item = await fetchItem(session.page, url, log, challenge);
|
|
241
|
+
if (!item) {
|
|
242
|
+
log(" Could not read this post (private, removed, or unreadable).", true);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// A link is whatever it is: a slideshow gets its images, a video gets
|
|
246
|
+
// its mp4 and a frame sheet, and refusing the second is only useful
|
|
247
|
+
// when nobody asked for it.
|
|
248
|
+
const written = item.imagePost
|
|
249
|
+
? await writeSlideshow(item, runDir, index + 1, log)
|
|
250
|
+
: includeVideos
|
|
251
|
+
? await writeVideoFrames(item, session.page, runDir, index + 1, log, { offsets: sheetOffsets })
|
|
252
|
+
: (log(" This post is a video; pass include_videos to pull it.", true), null);
|
|
253
|
+
if (written) saved.push(written);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
let position = 0;
|
|
257
|
+
for (const account of accounts) {
|
|
258
|
+
if (!(await gate())) break;
|
|
259
|
+
const handle = account.username.replace(/^@+/, "");
|
|
260
|
+
const perCount = Math.min(account.count ?? count, MAX_DOWNLOAD_COUNT);
|
|
261
|
+
const perSort = account.sort ?? sort;
|
|
262
|
+
const perDays = account.max_days ?? maxDays;
|
|
263
|
+
log(`\n@${handle}: taking the top ${perCount} by ${perSort}${perDays ? ` from the last ${perDays} days` : ""}.`);
|
|
264
|
+
|
|
265
|
+
const { posts } = await readProfilePosts({
|
|
266
|
+
page: session.page,
|
|
267
|
+
username: handle,
|
|
268
|
+
log,
|
|
269
|
+
gate,
|
|
270
|
+
challenge,
|
|
271
|
+
maxScrolls: 30,
|
|
272
|
+
until: perDays ? Date.now() / 1000 - perDays * 86400 : null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Read, then window, then rank, then take. The window is the ground the
|
|
276
|
+
// ranking happens on rather than a filter applied afterwards.
|
|
277
|
+
const cutoff = perDays ? Date.now() / 1000 - perDays * 86400 : 0;
|
|
278
|
+
const wanted = posts
|
|
279
|
+
.filter((p) => (includeVideos ? true : Boolean(p.imagePost)) && (p.createTime ?? 0) >= cutoff)
|
|
280
|
+
.sort((a, b) => SORTS[perSort](b) - SORTS[perSort](a))
|
|
281
|
+
.slice(0, perCount);
|
|
282
|
+
|
|
283
|
+
if (wanted.length === 0) {
|
|
284
|
+
log(` No ${includeVideos ? "posts" : "slideshows"} matched inside that window.`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const accountDir = path.join(runDir, slugify(handle));
|
|
288
|
+
fs.mkdirSync(accountDir, { recursive: true });
|
|
289
|
+
for (const post of wanted) {
|
|
290
|
+
if (!(await gate())) break;
|
|
291
|
+
position += 1;
|
|
292
|
+
const written = post.imagePost
|
|
293
|
+
? await writeSlideshow(post, accountDir, position, log)
|
|
294
|
+
: await writeVideoFrames(post, session.page, accountDir, position, log, { offsets: sheetOffsets });
|
|
295
|
+
if (written) saved.push({ ...written, username: handle });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
await session.release();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log(`\nDone. ${saved.length} post${saved.length === 1 ? "" : "s"} in ${runDir}`);
|
|
304
|
+
const sheets = saved.filter((entry) => entry.sheet).length;
|
|
305
|
+
return {
|
|
306
|
+
directory: runDir,
|
|
307
|
+
downloaded: saved.length,
|
|
308
|
+
frame_sheets: sheets,
|
|
309
|
+
read_the_sheets: sheets
|
|
310
|
+
? "Each video folder holds its frames and sheet.png: tiles left to right at the offsets in its metadata.json. Read it to describe the structure - the cover frame, the opening claim, when the first cut lands - never to republish it."
|
|
311
|
+
: undefined,
|
|
312
|
+
posts: saved.map((entry) => ({
|
|
313
|
+
folder: entry.folder,
|
|
314
|
+
type: entry.metadata.type ?? "slideshow",
|
|
315
|
+
slides: entry.slides,
|
|
316
|
+
sheet_offsets_seconds: entry.sheet ? entry.sheet.offsets : undefined,
|
|
317
|
+
url: entry.metadata.url,
|
|
318
|
+
views: entry.metadata.views,
|
|
319
|
+
engagement_rate: entry.metadata.engagement_rate,
|
|
320
|
+
})),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = { runDownload, DOWNLOAD_DEFAULTS, MAX_DOWNLOAD_COUNT, SORT_VALUES };
|
package/src/frames.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Frames off the player, rather than a file off a CDN.
|
|
4
|
+
//
|
|
5
|
+
// Measured before it was built this way: the signed playable url answers 403
|
|
6
|
+
// both from node and from inside the signed-in page, because the player runs on
|
|
7
|
+
// Media Source Extensions - its `<video>` src is a `blob:` handle with no bytes
|
|
8
|
+
// behind it to fetch. What does work is seeking that player and drawing each
|
|
9
|
+
// frame to a canvas, which is not tainted, so the pixels come back.
|
|
10
|
+
//
|
|
11
|
+
// It is also the better tool for the job. Nothing is copied to disk as a file,
|
|
12
|
+
// the length of the video costs nothing (a four-minute post is five seeks), and
|
|
13
|
+
// what comes back is what a teardown needs: the cover frame, and the seconds
|
|
14
|
+
// where a viewer decides.
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
|
|
19
|
+
/** Frame 0 is the cover the feed shows; 1-3s is where a viewer leaves. */
|
|
20
|
+
const DEFAULT_OFFSETS = [0, 1, 2, 5, 10];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The capture, as source to run inside the page. Returns a JSON string so it
|
|
24
|
+
* can cross any bridge - playwright's evaluate or an Apple Events round trip.
|
|
25
|
+
*/
|
|
26
|
+
function captureSource(offsets = DEFAULT_OFFSETS, width = 360) {
|
|
27
|
+
return `(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const video = document.querySelector('video');
|
|
30
|
+
if (!video) return JSON.stringify({ error: 'this page has no video player' });
|
|
31
|
+
for (let waited = 0; waited < 20 && !video.videoWidth; waited++) {
|
|
32
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
33
|
+
}
|
|
34
|
+
if (!video.videoWidth) return JSON.stringify({ error: 'the player never reported a size' });
|
|
35
|
+
const canvas = document.createElement('canvas');
|
|
36
|
+
const w = Math.min(video.videoWidth, ${width});
|
|
37
|
+
const h = Math.round((video.videoHeight / video.videoWidth) * w);
|
|
38
|
+
canvas.width = w; canvas.height = h;
|
|
39
|
+
const ctx = canvas.getContext('2d');
|
|
40
|
+
video.muted = true;
|
|
41
|
+
try { await video.play(); } catch (e) {}
|
|
42
|
+
const shots = [];
|
|
43
|
+
for (const at of ${JSON.stringify(offsets)}) {
|
|
44
|
+
if (video.duration && at >= video.duration) continue;
|
|
45
|
+
await new Promise((done) => {
|
|
46
|
+
const finish = () => { video.removeEventListener('seeked', finish); done(); };
|
|
47
|
+
video.addEventListener('seeked', finish);
|
|
48
|
+
video.currentTime = at;
|
|
49
|
+
setTimeout(finish, 2500);
|
|
50
|
+
});
|
|
51
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
52
|
+
ctx.drawImage(video, 0, 0, w, h);
|
|
53
|
+
shots.push({ at, data: canvas.toDataURL('image/jpeg', 0.72) });
|
|
54
|
+
}
|
|
55
|
+
try { video.pause(); } catch (e) {}
|
|
56
|
+
return JSON.stringify({ width: w, height: h, duration: video.duration || null, shots });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return JSON.stringify({ error: String((e && e.message) || e) });
|
|
59
|
+
}
|
|
60
|
+
})()`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {(source: string) => Promise<string>} evaluate runs the capture in the
|
|
65
|
+
* page and hands back whatever it returned. The two lanes differ only here.
|
|
66
|
+
*/
|
|
67
|
+
async function capturePlayer(evaluate, { offsets = DEFAULT_OFFSETS, width = 360 } = {}) {
|
|
68
|
+
const raw = await evaluate(captureSource(offsets, width));
|
|
69
|
+
if (!raw) throw new Error("the page returned nothing");
|
|
70
|
+
const payload = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
71
|
+
if (payload.error) throw new Error(payload.error);
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Writes one jpg per frame and returns their paths in offset order. */
|
|
76
|
+
function writeFrames(shots, dir) {
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
return shots.map(({ at, data }) => {
|
|
79
|
+
const file = path.join(dir, `frame_${String(at).replace(".", "_")}s.jpg`);
|
|
80
|
+
fs.writeFileSync(file, Buffer.from(String(data).split(",")[1] ?? "", "base64"));
|
|
81
|
+
return { at, file };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { capturePlayer, captureSource, writeFrames, DEFAULT_OFFSETS };
|