@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/quick.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// The fast lane on macOS: read a profile or a hashtag through the Chrome the
|
|
5
|
+
// user is already signed in to, instead of the dedicated research profile.
|
|
6
|
+
//
|
|
7
|
+
// It costs no login, because it borrows a session that already exists. What it
|
|
8
|
+
// costs instead is a browser-wide permission (View → Developer → Allow
|
|
9
|
+
// JavaScript from Apple Events), which lets any local script run JavaScript in
|
|
10
|
+
// any tab that browser has open. That is a real trade and the skill states it
|
|
11
|
+
// in those words; this file does not decide it for anyone.
|
|
12
|
+
//
|
|
13
|
+
// It opens its own tab and closes it again, so it never takes over the tab in
|
|
14
|
+
// front of the user.
|
|
15
|
+
//
|
|
16
|
+
// What it can and cannot do, measured rather than assumed:
|
|
17
|
+
// - The feed endpoint is signature-gated. Called directly it answers 200 with
|
|
18
|
+
// an empty body, and the page signs its own requests, so there is no way to
|
|
19
|
+
// ask it for JSON from here.
|
|
20
|
+
// - Patching fetch and XHR catches nothing either: a profile grid arrives
|
|
21
|
+
// with the document, and scrolling it produced no API calls at all.
|
|
22
|
+
// - What is there is the rendered grid: every post as a link, with its view
|
|
23
|
+
// count where the page prints one. That is enough to collect what to pull,
|
|
24
|
+
// and not enough to measure an account over a time window, because a tile
|
|
25
|
+
// carries no date. Measurement stays on the dedicated-profile lane.
|
|
26
|
+
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const path = require("node:path");
|
|
29
|
+
const { execFile } = require("node:child_process");
|
|
30
|
+
const util = require("node:util");
|
|
31
|
+
const { ROOT } = require("./config");
|
|
32
|
+
const { captureSource, writeFrames, DEFAULT_OFFSETS } = require("./frames");
|
|
33
|
+
const { sheetFromFrames, wallFromFrames, ffmpegAvailable } = require("./sheet");
|
|
34
|
+
|
|
35
|
+
const run = util.promisify(execFile);
|
|
36
|
+
|
|
37
|
+
const NOT_ENABLED =
|
|
38
|
+
"Chrome refused the JavaScript. Turn on View → Developer → Allow JavaScript from Apple Events " +
|
|
39
|
+
"in the Chrome the user is signed in to, or use the dedicated-profile lane instead " +
|
|
40
|
+
"(npm run login, then the normal tools).";
|
|
41
|
+
|
|
42
|
+
/** AppleScript string literal: backslashes and quotes only. */
|
|
43
|
+
function osaString(value) {
|
|
44
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function osascript(lines) {
|
|
48
|
+
const args = lines.flatMap((line) => ["-e", line]);
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await run("osascript", args, { maxBuffer: 64 * 1024 * 1024 });
|
|
51
|
+
return stdout.trim();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const text = String(error.stderr || error.message);
|
|
54
|
+
if (/-1723|not allowed|javascript through applescript|-2700/i.test(text)) throw new Error(NOT_ENABLED);
|
|
55
|
+
if (/Application isn't running|-600/.test(text)) {
|
|
56
|
+
throw new Error("Google Chrome is not running. Open it, signed in to TikTok, and try again.");
|
|
57
|
+
}
|
|
58
|
+
throw new Error(text.split("\n")[0]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** "@handle", "handle", "#tag" or "tag:name" → the page to open. */
|
|
63
|
+
function targetUrl(target) {
|
|
64
|
+
const value = String(target).trim();
|
|
65
|
+
if (/^#/.test(value)) return { kind: "hashtag", name: value.slice(1), url: `https://www.tiktok.com/tag/${encodeURIComponent(value.slice(1))}` };
|
|
66
|
+
if (/^tag:/i.test(value)) return { kind: "hashtag", name: value.slice(4), url: `https://www.tiktok.com/tag/${encodeURIComponent(value.slice(4))}` };
|
|
67
|
+
const handle = value.replace(/^@/, "");
|
|
68
|
+
return { kind: "account", name: handle, url: `https://www.tiktok.com/@${handle}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Apple Events JavaScript is synchronous: it hands back the value of the last
|
|
72
|
+
// expression and cannot wait for a promise, so an async body returns nothing at
|
|
73
|
+
// all. The work is started in one call, parked on `window`, and collected by a
|
|
74
|
+
// second call that polls for it.
|
|
75
|
+
function starterScript(rounds) {
|
|
76
|
+
return `(() => { window.__SCOUT_RESULT__ = null; (async () => {
|
|
77
|
+
const seen = new Map();
|
|
78
|
+
const viewsOf = (tile) => {
|
|
79
|
+
const el = tile.querySelector('[data-e2e*="views"], strong');
|
|
80
|
+
const raw = el ? el.textContent.trim() : '';
|
|
81
|
+
const m = raw.match(/^([\\d.,]+)\\s*([KMB])?$/i);
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
const n = parseFloat(m[1].replace(/,/g, ''));
|
|
84
|
+
const mult = { k: 1e3, m: 1e6, b: 1e9 }[(m[2] || '').toLowerCase()] || 1;
|
|
85
|
+
return Math.round(n * mult);
|
|
86
|
+
};
|
|
87
|
+
const collect = () => {
|
|
88
|
+
for (const a of document.querySelectorAll('a[href*="/video/"], a[href*="/photo/"]')) {
|
|
89
|
+
const m = a.href.match(/tiktok\\.com\\/@([^/]+)\\/(video|photo)\\/(\\d+)/);
|
|
90
|
+
if (!m || seen.has(m[3])) continue;
|
|
91
|
+
const tile = a.closest('[data-e2e="challenge-item"], [data-e2e="user-post-item"], div');
|
|
92
|
+
seen.set(m[3], { url: a.href.split('?')[0], handle: m[1], type: m[2] === 'photo' ? 'slideshow' : 'video', id: m[3], views: tile ? viewsOf(tile) : null });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
collect();
|
|
97
|
+
for (let round = 0; round < ${rounds}; round++) {
|
|
98
|
+
const before = seen.size;
|
|
99
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
100
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
101
|
+
collect();
|
|
102
|
+
if (seen.size === before && round > 1) break;
|
|
103
|
+
}
|
|
104
|
+
const el = document.getElementById('__UNIVERSAL_DATA_FOR_REHYDRATION__');
|
|
105
|
+
const scope = el ? JSON.parse(el.textContent).__DEFAULT_SCOPE__ : null;
|
|
106
|
+
const info = scope && scope['webapp.user-detail'] && scope['webapp.user-detail'].userInfo;
|
|
107
|
+
const stats = info ? Object.assign({}, info.stats, info.statsV2) : null;
|
|
108
|
+
window.__SCOUT_RESULT__ = JSON.stringify({
|
|
109
|
+
user: info && info.user ? { username: info.user.uniqueId, display_name: info.user.nickname,
|
|
110
|
+
bio: info.user.signature, verified: !!info.user.verified,
|
|
111
|
+
followers: Number(stats.followerCount) || 0, posts: Number(stats.videoCount) || 0 } : null,
|
|
112
|
+
title: document.title,
|
|
113
|
+
posts: [...seen.values()],
|
|
114
|
+
});
|
|
115
|
+
} catch (e) { window.__SCOUT_RESULT__ = JSON.stringify({ error: String((e && e.message) || e) }); }
|
|
116
|
+
})(); return 'started'; })()`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const COLLECT_SCRIPT = '(() => window.__SCOUT_RESULT__ || "")()';
|
|
120
|
+
|
|
121
|
+
/** Opens its own tab, reads the grid, closes it. Never touches the tab in front. */
|
|
122
|
+
async function collect(target, { rounds = 10, timeoutMs = 90_000 } = {}) {
|
|
123
|
+
const where = targetUrl(target);
|
|
124
|
+
await osascript([
|
|
125
|
+
'tell application "Google Chrome"',
|
|
126
|
+
` make new tab at end of tabs of front window with properties {URL:${osaString(where.url)}}`,
|
|
127
|
+
"end tell",
|
|
128
|
+
]);
|
|
129
|
+
// The document has to exist before anything can read it. This is a settle,
|
|
130
|
+
// not a load wait: the grid arrives with the document.
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
132
|
+
let raw = "";
|
|
133
|
+
try {
|
|
134
|
+
await osascript([
|
|
135
|
+
'tell application "Google Chrome"',
|
|
136
|
+
" set t to last tab of front window",
|
|
137
|
+
` execute t javascript ${osaString(starterScript(rounds))}`,
|
|
138
|
+
"end tell",
|
|
139
|
+
]);
|
|
140
|
+
const deadline = Date.now() + timeoutMs;
|
|
141
|
+
while (!raw && Date.now() < deadline) {
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
143
|
+
raw = await osascript([
|
|
144
|
+
'tell application "Google Chrome"',
|
|
145
|
+
" set t to last tab of front window",
|
|
146
|
+
` execute t javascript ${osaString(COLLECT_SCRIPT)}`,
|
|
147
|
+
"end tell",
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
await osascript([
|
|
152
|
+
'tell application "Google Chrome"',
|
|
153
|
+
" try",
|
|
154
|
+
" close last tab of front window",
|
|
155
|
+
" end try",
|
|
156
|
+
"end tell",
|
|
157
|
+
]).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
if (!raw) throw new Error("Chrome returned nothing before the timeout. The page may not have loaded.");
|
|
160
|
+
const payload = JSON.parse(raw);
|
|
161
|
+
if (payload.error) throw new Error(`The page answered: ${payload.error}`);
|
|
162
|
+
const posts = payload.posts ?? [];
|
|
163
|
+
return {
|
|
164
|
+
lane: "your own Chrome, via Apple Events",
|
|
165
|
+
target: where.name,
|
|
166
|
+
kind: where.kind,
|
|
167
|
+
url: where.url,
|
|
168
|
+
user: payload.user,
|
|
169
|
+
found: posts.length,
|
|
170
|
+
slideshows: posts.filter((p) => p.type === "slideshow").length,
|
|
171
|
+
posts: posts.sort((a, b) => (b.views ?? 0) - (a.views ?? 0)),
|
|
172
|
+
next: "Pull the media with the download tool, passing these urls as links.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const POST_URL = /tiktok\.com\/@([^/]+)\/(video|photo)\/(\d+)/;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One post, read as frames off its own player, through the Chrome the user is
|
|
180
|
+
* already signed in to. The same capture the dedicated lane runs; only the
|
|
181
|
+
* bridge into the page differs.
|
|
182
|
+
*/
|
|
183
|
+
async function captureFrames(url, { offsets = DEFAULT_OFFSETS, outDir } = {}) {
|
|
184
|
+
const match = String(url).match(POST_URL);
|
|
185
|
+
if (!match) throw new Error("Give the url of a post, e.g. https://www.tiktok.com/@handle/video/123");
|
|
186
|
+
await osascript([
|
|
187
|
+
'tell application "Google Chrome"',
|
|
188
|
+
` make new tab at end of tabs of front window with properties {URL:${osaString(url)}}`,
|
|
189
|
+
"end tell",
|
|
190
|
+
]);
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 7000));
|
|
192
|
+
const starter = `(() => { window.__SCOUT_FRAMES__ = null; (${captureSource(offsets)})
|
|
193
|
+
.then((value) => { window.__SCOUT_FRAMES__ = value; })
|
|
194
|
+
.catch((e) => { window.__SCOUT_FRAMES__ = JSON.stringify({ error: String((e && e.message) || e) }); });
|
|
195
|
+
return "started"; })()`;
|
|
196
|
+
let raw = "";
|
|
197
|
+
try {
|
|
198
|
+
await osascript([
|
|
199
|
+
'tell application "Google Chrome"',
|
|
200
|
+
" set t to last tab of front window",
|
|
201
|
+
` execute t javascript ${osaString(starter)}`,
|
|
202
|
+
"end tell",
|
|
203
|
+
]);
|
|
204
|
+
const deadline = Date.now() + 90_000;
|
|
205
|
+
while (!raw && Date.now() < deadline) {
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
207
|
+
raw = await osascript([
|
|
208
|
+
'tell application "Google Chrome"',
|
|
209
|
+
" set t to last tab of front window",
|
|
210
|
+
` execute t javascript ${osaString('(() => window.__SCOUT_FRAMES__ || "")()')}`,
|
|
211
|
+
"end tell",
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
} finally {
|
|
215
|
+
await osascript([
|
|
216
|
+
'tell application "Google Chrome"',
|
|
217
|
+
" try",
|
|
218
|
+
" close last tab of front window",
|
|
219
|
+
" end try",
|
|
220
|
+
"end tell",
|
|
221
|
+
]).catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
if (!raw) throw new Error("The player gave nothing back before the timeout.");
|
|
224
|
+
const payload = JSON.parse(raw);
|
|
225
|
+
if (payload.error) throw new Error(payload.error);
|
|
226
|
+
|
|
227
|
+
const dir = outDir ?? path.join(ROOT, "frames", `${match[1]}_${match[3]}`);
|
|
228
|
+
const frames = writeFrames(payload.shots, dir);
|
|
229
|
+
let sheet = null;
|
|
230
|
+
if (await ffmpegAvailable()) {
|
|
231
|
+
sheet = await sheetFromFrames(frames, path.join(dir, "sheet.png")).catch(() => null);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
lane: "your own Chrome, via Apple Events",
|
|
235
|
+
url,
|
|
236
|
+
handle: match[1],
|
|
237
|
+
id: match[3],
|
|
238
|
+
duration_seconds: payload.duration,
|
|
239
|
+
directory: dir,
|
|
240
|
+
frames: frames.map((f) => ({ at: f.at, file: path.basename(f.file) })),
|
|
241
|
+
sheet: sheet ? path.basename(sheet.file) : null,
|
|
242
|
+
read_it:
|
|
243
|
+
"Tiles left to right at those offsets. Frame 0 is the cover the feed shows before anyone presses anything.",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The same instant across many posts, as one image.
|
|
250
|
+
*
|
|
251
|
+
* `captureFrames` reads one post down time; this reads a niche across posts at
|
|
252
|
+
* a fixed second. Frame 0 is the default because that is the cover the feed
|
|
253
|
+
* shows, and a wall of covers is the fastest way to see what a category has
|
|
254
|
+
* agreed on and what nobody is doing.
|
|
255
|
+
*/
|
|
256
|
+
async function captureWall(urls, { at = 0, outDir, cols = 4 } = {}) {
|
|
257
|
+
const dir = outDir ?? path.join(ROOT, "walls", `${Date.now()}`);
|
|
258
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
259
|
+
const cells = [];
|
|
260
|
+
const failures = [];
|
|
261
|
+
for (const [index, url] of urls.entries()) {
|
|
262
|
+
try {
|
|
263
|
+
const one = await captureFrames(url, { offsets: [at], outDir: path.join(dir, `cell_${index}`) });
|
|
264
|
+
const frame = path.join(one.directory, one.frames[0].file);
|
|
265
|
+
cells.push({ at, file: frame, url, handle: one.handle });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
failures.push({ url, why: String(error.message).slice(0, 120) });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (cells.length === 0) throw new Error("No post gave a frame back.");
|
|
271
|
+
const wall = (await ffmpegAvailable())
|
|
272
|
+
? await wallFromFrames(cells, path.join(dir, `wall_${at}s.png`), { cols })
|
|
273
|
+
: null;
|
|
274
|
+
return {
|
|
275
|
+
lane: "your own Chrome, via Apple Events",
|
|
276
|
+
at_seconds: at,
|
|
277
|
+
directory: dir,
|
|
278
|
+
cells: cells.map((c, i) => ({ position: i + 1, handle: c.handle, url: c.url })),
|
|
279
|
+
wall: wall ? path.basename(wall.file) : null,
|
|
280
|
+
skipped: failures,
|
|
281
|
+
read_it:
|
|
282
|
+
`Left to right, top to bottom, in the order listed. Every cell is the same second (${at}s) of a different post, ` +
|
|
283
|
+
"so what repeats across the wall is the category's habit and what appears once is the opening.",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = { collect, captureFrames, captureWall, targetUrl, NOT_ENABLED };
|
|
288
|
+
|
|
289
|
+
if (require.main === module) {
|
|
290
|
+
const [target, ...rest] = process.argv.slice(2);
|
|
291
|
+
if (!target) {
|
|
292
|
+
console.error("Usage: sprid-scout-quick <@handle | #hashtag | post-url> [--rounds 10] [--links-only] [--at 0,1,2,5,10]\n sprid-scout-quick wall <post-url> <post-url> ... [--at-second 0]");
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
const roundsAt = rest.indexOf("--rounds");
|
|
296
|
+
const atAt = rest.indexOf("--at");
|
|
297
|
+
const offsets = atAt === -1 ? undefined : String(rest[atAt + 1]).split(",").map(Number).filter((n) => Number.isFinite(n));
|
|
298
|
+
const tAt = rest.indexOf("--at-second");
|
|
299
|
+
const wallUrls = target === "wall" ? rest.filter((a) => POST_URL.test(a)) : [];
|
|
300
|
+
const work =
|
|
301
|
+
target === "wall"
|
|
302
|
+
? captureWall(wallUrls, { at: tAt === -1 ? 0 : Number(rest[tAt + 1]) })
|
|
303
|
+
: POST_URL.test(target)
|
|
304
|
+
? captureFrames(target, { offsets })
|
|
305
|
+
: collect(target, { rounds: roundsAt === -1 ? 10 : Number(rest[roundsAt + 1]) });
|
|
306
|
+
work
|
|
307
|
+
.then((result) => {
|
|
308
|
+
if (rest.includes("--links-only") && result.posts) {
|
|
309
|
+
for (const post of result.posts) console.log(post.url);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
console.log(JSON.stringify(result, null, 2));
|
|
313
|
+
})
|
|
314
|
+
.catch((error) => {
|
|
315
|
+
console.error(error.message);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
});
|
|
318
|
+
}
|
package/src/register.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Registers this server with the agents that speak MCP over stdio. The skill
|
|
5
|
+
// that teaches an agent how to use it ships with the Sprid plugin, so nothing
|
|
6
|
+
// is copied into a skills directory from here: one text, one home.
|
|
7
|
+
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const { execFileSync } = require("node:child_process");
|
|
10
|
+
const { NAME } = require("./config");
|
|
11
|
+
|
|
12
|
+
const SERVER = path.join(__dirname, "server.js");
|
|
13
|
+
const ADD = ["mcp", "add", "--transport", "stdio", "--scope", "user", NAME, "--", process.execPath, SERVER];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
execFileSync("claude", ["mcp", "remove", "--scope", "user", NAME], { stdio: "ignore" });
|
|
17
|
+
} catch {}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const output = execFileSync("claude", ADD, { encoding: "utf8" });
|
|
21
|
+
console.log(`- Claude Code: ${output.trim() || "registered"}`);
|
|
22
|
+
} catch {
|
|
23
|
+
console.log("- Claude Code: could not run `claude mcp add`. Register it by hand with:");
|
|
24
|
+
console.log(`\n claude mcp add --transport stdio --scope user ${NAME} -- ${process.execPath} ${SERVER}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log("\nDone. Start a new session, then ask it to log in to TikTok.");
|