@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.
@@ -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 };