@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/src/jobs.js ADDED
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+
3
+ const { randomUUID } = require("node:crypto");
4
+ const { EventEmitter } = require("node:events");
5
+
6
+ // Runs are async by construction: a tool starts one and hands back an id, and
7
+ // the caller follows it with await_job. Nothing blocks a tool call for minutes.
8
+
9
+ const bus = new EventEmitter();
10
+ bus.setMaxListeners(0);
11
+
12
+ const jobs = new Map();
13
+ const IN_FLIGHT = new Set(["running", "paused"]);
14
+
15
+ const AWAIT_DEFAULT_SECONDS = 900;
16
+ const AWAIT_MIN_SECONDS = 10;
17
+ const AWAIT_MAX_SECONDS = 3600;
18
+ const PROGRESS_INTERVAL_MS = 20_000;
19
+ const EVENT_COALESCE_MS = 1500;
20
+ const PROJECTION_MIN_PROFILES = 10;
21
+ const PROJECTION_JUMP_FACTOR = 3;
22
+
23
+ function emit(type, job, extra) {
24
+ bus.emit("job", { type, jobId: job.id, job, ...extra });
25
+ }
26
+
27
+ function createJob(tool, meta = {}) {
28
+ const job = {
29
+ id: `${tool}-${randomUUID().slice(0, 8)}`,
30
+ tool,
31
+ status: "running",
32
+ startedAt: Date.now(),
33
+ finishedAt: null,
34
+ logs: [],
35
+ alert: null,
36
+ error: null,
37
+ result: null,
38
+ stopRequested: false,
39
+ meta,
40
+ // Discovery keeps a live picture of itself here as it measures.
41
+ state: { measured: [], keywordsConsumed: 0, keywordsTotal: 0, target: meta.maxAccounts ?? 0 },
42
+ };
43
+ jobs.set(job.id, job);
44
+ return job;
45
+ }
46
+
47
+ function findJob(id) {
48
+ return jobs.get(id) ?? null;
49
+ }
50
+
51
+ function listJobs() {
52
+ return [...jobs.values()];
53
+ }
54
+
55
+ function inFlight() {
56
+ return listJobs().filter((job) => IN_FLIGHT.has(job.status));
57
+ }
58
+
59
+ function busyWith(tool) {
60
+ return inFlight().find((job) => job.tool === tool) ?? null;
61
+ }
62
+
63
+ function log(job, text, isAlert = false) {
64
+ job.logs.push({ at: Date.now(), text: String(text), alert: isAlert });
65
+ if (job.logs.length > 400) job.logs.splice(0, job.logs.length - 400);
66
+ emit("log", job);
67
+ }
68
+
69
+ function setAlert(job, kind, message) {
70
+ job.alert = { kind, message, at: Date.now() };
71
+ job.status = "paused";
72
+ emit("state", job);
73
+ }
74
+
75
+ function clearAlert(job) {
76
+ job.alert = null;
77
+ emit("state", job);
78
+ }
79
+
80
+ /**
81
+ * Every loop calls this between steps. It parks while paused and answers false
82
+ * when the run has been told to stop, which is how a stop takes effect without
83
+ * killing the browser mid-page.
84
+ */
85
+ function gate(job) {
86
+ return async () => {
87
+ if (job.stopRequested) return false;
88
+ while (job.status === "paused" && !job.stopRequested) {
89
+ await new Promise((resolve) => setTimeout(resolve, 400));
90
+ }
91
+ return !job.stopRequested;
92
+ };
93
+ }
94
+
95
+ function control(job, action) {
96
+ if (!IN_FLIGHT.has(job.status)) return { ok: false, reason: `Run is already ${job.status}.` };
97
+ if (action === "pause") job.status = "paused";
98
+ if (action === "resume") {
99
+ job.status = "running";
100
+ job.alert = null;
101
+ }
102
+ if (action === "stop") {
103
+ job.stopRequested = true;
104
+ job.status = "running";
105
+ }
106
+ emit("state", job);
107
+ return { ok: true };
108
+ }
109
+
110
+ function settle(job, status, payload) {
111
+ job.status = status;
112
+ job.finishedAt = Date.now();
113
+ if (status === "error") job.error = payload;
114
+ else job.result = payload;
115
+ emit("settled", job);
116
+ }
117
+
118
+ // ---- vitals ----------------------------------------------------------------
119
+
120
+ function elapsedSeconds(job) {
121
+ return Math.round(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000);
122
+ }
123
+
124
+ function humanElapsed(job) {
125
+ const seconds = elapsedSeconds(job);
126
+ if (seconds < 60) return `${seconds}s`;
127
+ const minutes = Math.floor(seconds / 60);
128
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
129
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
130
+ }
131
+
132
+ /**
133
+ * The run's own arithmetic about itself. The projection is computed from the
134
+ * rate actually measured, so it is a reading for the caller rather than a
135
+ * promise to hand anyone.
136
+ */
137
+ function vitals(job) {
138
+ const measured = job.state.measured;
139
+ const passed = measured.filter((a) => a.verdict?.passed);
140
+ const profilesMeasured = measured.length;
141
+ const passRate = profilesMeasured > 0 ? passed.length / profilesMeasured : 0;
142
+ const secondsPerProfile = profilesMeasured > 0 ? elapsedSeconds(job) / profilesMeasured : null;
143
+ const target = job.state.target || 0;
144
+ const missing = Math.max(0, target - passed.length);
145
+ let projectedSecondsToTarget = null;
146
+ if (profilesMeasured > 0 && passRate > 0 && secondsPerProfile !== null && missing > 0) {
147
+ projectedSecondsToTarget = Math.round((missing / passRate) * secondsPerProfile);
148
+ }
149
+ return {
150
+ profilesMeasured,
151
+ accountsPassed: passed.length,
152
+ target,
153
+ passRate: Math.round(passRate * 1000) / 1000,
154
+ secondsPerProfile: secondsPerProfile === null ? null : Math.round(secondsPerProfile),
155
+ keywordsConsumed: job.state.keywordsConsumed,
156
+ keywordsRemaining: Math.max(0, job.state.keywordsTotal - job.state.keywordsConsumed),
157
+ projectedSecondsToTarget,
158
+ };
159
+ }
160
+
161
+ function vitalsFields(v) {
162
+ return {
163
+ profiles_measured: v.profilesMeasured,
164
+ accounts_passed: v.accountsPassed,
165
+ target: v.target,
166
+ pass_rate: v.passRate,
167
+ seconds_per_profile: v.secondsPerProfile,
168
+ keywords_consumed: v.keywordsConsumed,
169
+ keywords_remaining: v.keywordsRemaining,
170
+ projected_seconds_to_target: v.projectedSecondsToTarget,
171
+ };
172
+ }
173
+
174
+ // ---- discovery event diffing ----------------------------------------------
175
+
176
+ function discoveryBaseline(job) {
177
+ const measured = job.state.measured;
178
+ return {
179
+ passed: new Set(measured.filter((a) => a.verdict?.passed).map((a) => a.username)),
180
+ marksHigh: new Set(
181
+ measured.filter((a) => a.verdict?.highViewsPerFollower).map((a) => a.username)
182
+ ),
183
+ marksNear: new Set(measured.filter((a) => a.verdict?.nearMiss).map((a) => a.username)),
184
+ keywordsRemaining: vitals(job).keywordsRemaining,
185
+ projection: vitals(job).projectedSecondsToTarget,
186
+ };
187
+ }
188
+
189
+ function diffDiscovery(job, baseline, summarise) {
190
+ const measured = job.state.measured;
191
+ const newPassed = measured.filter((a) => a.verdict?.passed && !baseline.passed.has(a.username));
192
+ const newHigh = measured
193
+ .filter((a) => a.verdict?.highViewsPerFollower && !baseline.marksHigh.has(a.username))
194
+ .map((a) => a.username);
195
+ const newNear = measured
196
+ .filter((a) => a.verdict?.nearMiss && !baseline.marksNear.has(a.username))
197
+ .map((a) => a.username);
198
+ const v = vitals(job);
199
+ const fired = [];
200
+ if (newPassed.length > 0) fired.push("account_passed");
201
+ if (newHigh.length + newNear.length > 0) fired.push("mark_assigned");
202
+ if (v.keywordsRemaining === 0 && baseline.keywordsRemaining > 0) fired.push("keywords_exhausted");
203
+ if (
204
+ v.profilesMeasured >= PROJECTION_MIN_PROFILES &&
205
+ baseline.projection !== null &&
206
+ baseline.projection > 0 &&
207
+ v.projectedSecondsToTarget !== null &&
208
+ v.projectedSecondsToTarget > PROJECTION_JUMP_FACTOR * baseline.projection
209
+ ) {
210
+ fired.push("projection_jumped");
211
+ }
212
+ return {
213
+ fired,
214
+ extra: {
215
+ wake_events: fired,
216
+ summary: {
217
+ vitals: vitalsFields(v),
218
+ new_passed: newPassed.map(summarise),
219
+ new_marks: { high_views_per_follower: newHigh, near_miss: newNear },
220
+ },
221
+ },
222
+ };
223
+ }
224
+
225
+ // ---- awaiting --------------------------------------------------------------
226
+
227
+ const surfacedAlert = new Map();
228
+
229
+ function baseStatus(job) {
230
+ return {
231
+ job_id: job.id,
232
+ tool: job.tool,
233
+ status: job.status,
234
+ elapsed: humanElapsed(job),
235
+ started_at: new Date(job.startedAt).toISOString(),
236
+ recent_logs: job.logs.slice(-8).map((entry) => entry.text),
237
+ alert: job.alert ? { kind: job.alert.kind, message: job.alert.message } : null,
238
+ error: job.error,
239
+ };
240
+ }
241
+
242
+ function nextStep(job) {
243
+ if (job.status === "done") return "Call job_results for what the run collected.";
244
+ if (job.status === "error") return `The run failed: ${job.error}. Nothing else will happen.`;
245
+ if (job.status === "stopped") return "The run was stopped. job_results still reads what it measured before it ended.";
246
+ if (job.alert?.kind === "captcha") {
247
+ return "TikTok put a verification in front of the browser. Tell the user to solve it in the Chrome window, then call job_control with resume.";
248
+ }
249
+ if (job.alert?.kind === "bot") {
250
+ return "TikTok has stopped serving this session. Tell the user to check the Chrome window; they may need to wait or log in with another account, then call job_control with resume.";
251
+ }
252
+ if (job.alert?.kind === "unreadable") {
253
+ return "Something came back empty, usually a private or removed post. Call job_control with resume to try once more, or stop.";
254
+ }
255
+ if (job.status === "paused") return "The run is paused. Call job_control with resume to continue it.";
256
+ return "The run is working. Call await_job to be told the moment it finishes or needs a person.";
257
+ }
258
+
259
+ function awaitResult(job, event, extra = {}) {
260
+ if (event === "alert" && job.alert) surfacedAlert.set(job.id, job.alert);
261
+ return { ...baseStatus(job), event, next_step: nextStep(job), ...extra };
262
+ }
263
+
264
+ /**
265
+ * Blocks until the run does something worth hearing about. Progress
266
+ * notifications go out on a heartbeat so a long wait is not mistaken for a
267
+ * dead call.
268
+ */
269
+ function waitForJob(job, timeoutSeconds, extra, wakeOn, summarise) {
270
+ return new Promise((resolve, reject) => {
271
+ const baselineStatus = job.status;
272
+ const baselineAlertKind = job.alert?.kind ?? null;
273
+ const watchEvents = wakeOn === "events" && job.tool === "discover";
274
+ const eventBaseline = watchEvents ? discoveryBaseline(job) : null;
275
+ let done = false;
276
+ let ticks = 0;
277
+ let coalesceTimer = null;
278
+
279
+ const cleanup = () => {
280
+ bus.off("job", onEvent);
281
+ clearInterval(heartbeat);
282
+ clearTimeout(timer);
283
+ if (coalesceTimer) clearTimeout(coalesceTimer);
284
+ extra.signal?.removeEventListener("abort", onAbort);
285
+ };
286
+ const finish = (event, eventsExtra) => {
287
+ if (done) return;
288
+ done = true;
289
+ cleanup();
290
+ resolve(awaitResult(job, event, eventsExtra));
291
+ };
292
+ const onAbort = () => {
293
+ if (done) return;
294
+ done = true;
295
+ cleanup();
296
+ reject(extra.signal?.reason ?? new Error("await_job was cancelled"));
297
+ };
298
+ const considerEvents = () => {
299
+ if (!watchEvents || !eventBaseline || coalesceTimer) return;
300
+ const diff = diffDiscovery(job, eventBaseline, summarise);
301
+ if (diff.fired.length === 0) return;
302
+ // Bursts are gathered into one wakeup rather than one per account.
303
+ coalesceTimer = setTimeout(() => {
304
+ coalesceTimer = null;
305
+ const settled = diffDiscovery(job, eventBaseline, summarise);
306
+ if (settled.fired.length > 0) finish("events", settled.extra);
307
+ }, EVENT_COALESCE_MS);
308
+ };
309
+ const onEvent = (jobEvent) => {
310
+ if (jobEvent.jobId !== job.id) return;
311
+ if (jobEvent.type === "settled") return finish("settled");
312
+ if (jobEvent.type === "state") {
313
+ const kind = job.alert?.kind ?? null;
314
+ if (kind !== null && kind !== baselineAlertKind) finish("alert");
315
+ else if (job.status !== baselineStatus) finish("state");
316
+ return;
317
+ }
318
+ considerEvents();
319
+ };
320
+ bus.on("job", onEvent);
321
+
322
+ const heartbeat = setInterval(() => {
323
+ const token = extra._meta?.progressToken;
324
+ if (token === undefined) return;
325
+ ticks += 1;
326
+ const last = job.logs.at(-1)?.text.replace(/\s+/g, " ").trim();
327
+ const message =
328
+ last && last.length > 0
329
+ ? last
330
+ : `Still waiting on ${job.tool} run ${job.id}, ${humanElapsed(job)} in.`;
331
+ void extra.sendNotification?.({
332
+ method: "notifications/progress",
333
+ params: { progressToken: token, progress: ticks, message },
334
+ }).catch(() => {});
335
+ }, PROGRESS_INTERVAL_MS);
336
+
337
+ const timer = setTimeout(() => finish("timeout"), timeoutSeconds * 1000);
338
+ if (extra.signal?.aborted) onAbort();
339
+ else extra.signal?.addEventListener("abort", onAbort);
340
+ });
341
+ }
342
+
343
+ module.exports = {
344
+ bus,
345
+ createJob,
346
+ findJob,
347
+ listJobs,
348
+ inFlight,
349
+ busyWith,
350
+ log,
351
+ setAlert,
352
+ clearAlert,
353
+ gate,
354
+ control,
355
+ settle,
356
+ emit,
357
+ vitals,
358
+ vitalsFields,
359
+ baseStatus,
360
+ nextStep,
361
+ awaitResult,
362
+ waitForJob,
363
+ humanElapsed,
364
+ elapsedSeconds,
365
+ surfacedAlert,
366
+ IN_FLIGHT,
367
+ AWAIT_DEFAULT_SECONDS,
368
+ AWAIT_MIN_SECONDS,
369
+ AWAIT_MAX_SECONDS,
370
+ };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Log in without going through an agent. Opens the window, waits, exits.
5
+
6
+ const chrome = require("./chrome");
7
+ const { chromeUnavailable, ensureDirs } = require("./config");
8
+
9
+ async function main() {
10
+ ensureDirs();
11
+ const missing = chromeUnavailable();
12
+ if (missing) {
13
+ console.error(missing);
14
+ process.exit(1);
15
+ }
16
+ if (chrome.isLoggedIn()) {
17
+ console.log("A TikTok session is already saved. Nothing to do.");
18
+ return;
19
+ }
20
+ console.log("Opening Chrome on TikTok's login page. Log in there; this waits up to 5 minutes.");
21
+ const { loggedIn } = await chrome.login(300_000);
22
+ console.log(loggedIn ? "Logged in. The session is saved." : "No session appeared before the timeout.");
23
+ await chrome.shutdown();
24
+ process.exit(loggedIn ? 0 : 1);
25
+ }
26
+
27
+ main().catch((error) => {
28
+ console.error(error instanceof Error ? error.message : String(error));
29
+ process.exit(1);
30
+ });
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Push a finished download run into Sprid as inspirations.
5
+ //
6
+ // This is the whole integration surface. Scout never runs inside Sprid
7
+ // and Sprid ships no scraping code: the carousels go up through the same
8
+ // public API a phone screenshot upload uses, authenticated with the user's
9
+ // own personal access token. Sprid's position is unchanged, which matters
10
+ // because it is mid-audit with TikTok for the Content Posting API.
11
+ //
12
+ // SPRID_URL=https://api.sprid.studio SPRID_PAT=sprd_... \
13
+ // node src/push-to-sprid.js <account-slug> <download-dir>
14
+ //
15
+ // The directory is whatever `job_results` reported for a download run, or any
16
+ // tree of folders each holding slide_*.jpg + metadata.json.
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+ const { LIBRARY_FILE } = require("./config");
21
+
22
+ const BASE = (process.env.SPRID_URL ?? "https://api.sprid.studio").replace(/\/+$/, "");
23
+ const PAT = process.env.SPRID_PAT;
24
+
25
+ function die(message) {
26
+ console.error(message);
27
+ process.exit(1);
28
+ }
29
+
30
+ async function api(route, init = {}) {
31
+ const response = await fetch(`${BASE}/api${route}`, {
32
+ ...init,
33
+ headers: {
34
+ Authorization: `Bearer ${PAT}`,
35
+ "Content-Type": "application/json",
36
+ ...(init.headers ?? {}),
37
+ },
38
+ });
39
+ if (!response.ok) {
40
+ throw new Error(`${response.status} ${route}: ${(await response.text()).slice(0, 300)}`);
41
+ }
42
+ return response.json();
43
+ }
44
+
45
+ /** Every folder holding a metadata.json, at any depth under root. */
46
+ function findSlideshows(root) {
47
+ const found = [];
48
+ const walk = (dir) => {
49
+ let entries;
50
+ try {
51
+ entries = fs.readdirSync(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
55
+ if (entries.some((e) => e.isFile() && e.name === "metadata.json")) {
56
+ found.push(dir);
57
+ return;
58
+ }
59
+ for (const entry of entries) {
60
+ if (entry.isDirectory()) walk(path.join(dir, entry.name));
61
+ }
62
+ };
63
+ walk(root);
64
+ return found.sort();
65
+ }
66
+
67
+ function slideFiles(dir) {
68
+ return fs
69
+ .readdirSync(dir)
70
+ .filter((name) => /^slide_\d+\.(jpg|jpeg|webp|png)$/i.test(name))
71
+ .sort();
72
+ }
73
+
74
+ function contentTypeFor(filename) {
75
+ if (/\.webp$/i.test(filename)) return "image/webp";
76
+ if (/\.png$/i.test(filename)) return "image/png";
77
+ return "image/jpeg";
78
+ }
79
+
80
+ /**
81
+ * The metadata.json scout writes, mapped onto the shape Sprid stores.
82
+ * Views-per-follower is computed here because scout knows the author's
83
+ * follower count from the account measurement and the file does not.
84
+ */
85
+ function toInspiration(meta, followers) {
86
+ const views = meta.views ?? null;
87
+ return {
88
+ source: "tiktok",
89
+ sourceUrl: meta.url,
90
+ title: (meta.description ?? "").slice(0, 120) || `@${meta.author?.username ?? "unknown"}`,
91
+ authorHandle: meta.author?.username ?? null,
92
+ caption: meta.description ?? "",
93
+ tags: meta.hashtags ?? [],
94
+ notes: `Pulled by scout. ${meta.slide_count ?? "?"} slides, published ${meta.published_at ?? "unknown"}.`,
95
+ authorFollowers: followers ?? undefined,
96
+ metrics: {
97
+ views: views ?? undefined,
98
+ likes: meta.likes ?? undefined,
99
+ comments: meta.comments ?? undefined,
100
+ shares: meta.shares ?? undefined,
101
+ favorites: meta.favorites ?? undefined,
102
+ engagementRate: meta.engagement_rate ?? undefined,
103
+ slideCount: meta.slide_count ?? undefined,
104
+ publishedAt: meta.published_at ?? undefined,
105
+ viewsPerFollower:
106
+ views != null && followers ? Math.round((views / followers) * 100) / 100 : undefined,
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Follower counts per handle, read from scout's own library. */
112
+ function followerIndex() {
113
+ const file = LIBRARY_FILE;
114
+ try {
115
+ const library = JSON.parse(fs.readFileSync(file, "utf8"));
116
+ const index = new Map();
117
+ for (const account of Object.values(library.accounts ?? {})) {
118
+ if (account.username && account.followers) {
119
+ index.set(String(account.username).toLowerCase(), account.followers);
120
+ }
121
+ }
122
+ return index;
123
+ } catch {
124
+ return new Map();
125
+ }
126
+ }
127
+
128
+ async function main() {
129
+ const [accountSlug, dir] = process.argv.slice(2);
130
+ if (!PAT) die("Set SPRID_PAT to a sprd_ token from /settings/tokens.");
131
+ if (!accountSlug || !dir) {
132
+ die("Usage: node src/push-to-sprid.js <account-slug> <download-dir>");
133
+ }
134
+ if (!fs.existsSync(dir)) die(`No such directory: ${dir}`);
135
+
136
+ const folders = findSlideshows(dir);
137
+ if (folders.length === 0) die(`No slideshows found under ${dir} (looking for metadata.json).`);
138
+
139
+ const followers = followerIndex();
140
+ console.log(`${folders.length} slideshow(s) to push to @${accountSlug}.`);
141
+ if (followers.size === 0) {
142
+ console.log("No follower counts in the local library, so these land without a benchmark.");
143
+ }
144
+
145
+ const created = [];
146
+ for (const [index, folder] of folders.entries()) {
147
+ const meta = JSON.parse(fs.readFileSync(path.join(folder, "metadata.json"), "utf8"));
148
+ const files = slideFiles(folder);
149
+ if (files.length === 0) {
150
+ console.log(` [${index + 1}/${folders.length}] ${path.basename(folder)}: no slide images, skipped`);
151
+ continue;
152
+ }
153
+
154
+ const handle = String(meta.author?.username ?? "").toLowerCase();
155
+ const body = {
156
+ accountSlug,
157
+ ...toInspiration(meta, followers.get(handle) ?? null),
158
+ slides: files.map((name, position) => ({
159
+ filename: name,
160
+ contentType: contentTypeFor(name),
161
+ // Sprid's vision pass refines these; position is the honest first
162
+ // guess and it is what the shape signature needs anyway.
163
+ role: position === 0 ? "hook" : position === files.length - 1 ? "last" : "body",
164
+ })),
165
+ };
166
+
167
+ const result = await api("/inspirations", { method: "POST", body: JSON.stringify(body) });
168
+
169
+ for (const [i, slide] of result.slides.entries()) {
170
+ if (!slide.uploadUrl) continue;
171
+ const bytes = fs.readFileSync(path.join(folder, files[i]));
172
+ const put = await fetch(slide.uploadUrl, {
173
+ method: "PUT",
174
+ headers: { "Content-Type": contentTypeFor(files[i]) },
175
+ body: bytes,
176
+ });
177
+ if (!put.ok) throw new Error(`Upload failed for ${files[i]}: ${put.status}`);
178
+ }
179
+
180
+ created.push(result.inspirationId);
181
+ console.log(
182
+ ` [${index + 1}/${folders.length}] @${meta.author?.username ?? "?"} · ${files.length} slides -> inspiration ${result.inspirationId}`,
183
+ );
184
+ }
185
+
186
+ if (created.length === 0) die("Nothing was pushed.");
187
+ console.log(`\n${created.length} inspiration(s) created.`);
188
+
189
+ }
190
+
191
+ main().catch((error) => {
192
+ console.error(error instanceof Error ? error.message : String(error));
193
+ process.exit(1);
194
+ });