@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/server.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
|
+
const { z } = require("zod");
|
|
7
|
+
|
|
8
|
+
const { NAME, ensureDirs, chromeUnavailable, PROFILE_DIR, DOWNLOADS_DIR } = require("./config");
|
|
9
|
+
const chrome = require("./chrome");
|
|
10
|
+
const store = require("./store");
|
|
11
|
+
const jobsLib = require("./jobs");
|
|
12
|
+
const { reevaluate, flagOutliers, breakdownOf } = require("./tiktok");
|
|
13
|
+
const { runDiscovery } = require("./discover");
|
|
14
|
+
const { runAnalysis } = require("./analyze");
|
|
15
|
+
const { runDownload, DOWNLOAD_DEFAULTS, MAX_DOWNLOAD_COUNT, SORT_VALUES } = require("./download");
|
|
16
|
+
const { runAdHarvest } = require("./ads/run");
|
|
17
|
+
|
|
18
|
+
const INSTRUCTIONS = [
|
|
19
|
+
`${NAME} is a niche research tool running on this user's own machine. It reads two`,
|
|
20
|
+
"sources: the accounts posting photo slideshows on TikTok, profile by profile, and the",
|
|
21
|
+
"ads currently running in the same niche in Meta's public ad library. What people",
|
|
22
|
+
"choose to watch, and what somebody is paying to put in front of them. Everything it",
|
|
23
|
+
"finds is collected into a local library. It drives a real browser, so a run takes",
|
|
24
|
+
"minutes rather than seconds, and the TikTok half depends on the user being signed in.",
|
|
25
|
+
"Call status first: it says whether that is true and what is already running.",
|
|
26
|
+
].join(" ");
|
|
27
|
+
|
|
28
|
+
function text(value) {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unknownJob(jobId) {
|
|
41
|
+
return fail(`No run with id ${jobId}. list_runs has the finished ones; status has what is in flight.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every browser tool goes through here first. */
|
|
45
|
+
function notReady() {
|
|
46
|
+
const missingChrome = chromeUnavailable();
|
|
47
|
+
if (missingChrome) return fail(missingChrome);
|
|
48
|
+
if (!chrome.isLoggedIn()) {
|
|
49
|
+
return fail(
|
|
50
|
+
"No TikTok session is saved on this machine, and every one of these tools reads TikTok as a logged-in user. Call login_tiktok: it opens a Chrome window for the user to log in once. Then stop and wait for them."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function busy(tool, label) {
|
|
57
|
+
const running = jobsLib.busyWith(tool);
|
|
58
|
+
if (!running) return null;
|
|
59
|
+
return fail(
|
|
60
|
+
`A ${label} run is already in flight (${running.id}). Only one at a time shares the browser. Follow it with await_job, or stop it with job_control.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function started(job, note) {
|
|
65
|
+
return text({
|
|
66
|
+
job_id: job.id,
|
|
67
|
+
tool: job.tool,
|
|
68
|
+
started_at: new Date(job.startedAt).toISOString(),
|
|
69
|
+
note,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The compact row a discovered account rides as, everywhere it is listed. */
|
|
74
|
+
function foundAccount(account) {
|
|
75
|
+
return {
|
|
76
|
+
username: account.username,
|
|
77
|
+
url: `https://www.tiktok.com/@${account.username}`,
|
|
78
|
+
display_name: account.display_name,
|
|
79
|
+
followers: account.followers,
|
|
80
|
+
verified: account.verified,
|
|
81
|
+
metrics: account.metrics,
|
|
82
|
+
passed: account.verdict?.passed ?? false,
|
|
83
|
+
failed: account.verdict?.failed ?? [],
|
|
84
|
+
no_posts_in_window: account.verdict?.noPostsInWindow ?? false,
|
|
85
|
+
unmeasured: account.verdict?.unmeasured ?? false,
|
|
86
|
+
high_views_per_follower: account.verdict?.highViewsPerFollower ?? false,
|
|
87
|
+
near_miss: account.verdict?.nearMiss ?? false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Strip the retained per-post samples before anything is filed or returned. */
|
|
92
|
+
function withoutSamples(account) {
|
|
93
|
+
const { samples, measuredAt, ...rest } = account;
|
|
94
|
+
return rest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function discoveryResults(job) {
|
|
98
|
+
const measured = job.state.measured;
|
|
99
|
+
const passed = measured.filter((a) => a.verdict?.passed);
|
|
100
|
+
return {
|
|
101
|
+
run_id: job.id,
|
|
102
|
+
name: job.meta.name,
|
|
103
|
+
keywords: job.meta.keywords,
|
|
104
|
+
filters: job.meta.filters,
|
|
105
|
+
status: job.status,
|
|
106
|
+
vitals: jobsLib.vitalsFields(jobsLib.vitals(job)),
|
|
107
|
+
passed_accounts: passed.map(foundAccount),
|
|
108
|
+
measured_accounts: measured.map(foundAccount),
|
|
109
|
+
rejection_breakdown: breakdownOf(measured),
|
|
110
|
+
outliers: {
|
|
111
|
+
high_views_per_follower: measured
|
|
112
|
+
.filter((a) => a.verdict?.highViewsPerFollower)
|
|
113
|
+
.map((a) => a.username),
|
|
114
|
+
near_miss: measured.filter((a) => a.verdict?.nearMiss).map((a) => a.username),
|
|
115
|
+
},
|
|
116
|
+
tuning_history: job.meta.tuning ?? [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Runs are launched, never awaited. The caller follows them with await_job. */
|
|
121
|
+
function launch(job, work, onDone) {
|
|
122
|
+
work(job)
|
|
123
|
+
.then((result) => {
|
|
124
|
+
if (job.stopRequested) {
|
|
125
|
+
jobsLib.settle(job, "stopped", result);
|
|
126
|
+
} else {
|
|
127
|
+
jobsLib.settle(job, "done", result);
|
|
128
|
+
}
|
|
129
|
+
onDone?.(job);
|
|
130
|
+
})
|
|
131
|
+
.catch((error) => {
|
|
132
|
+
jobsLib.settle(job, "error", error instanceof Error ? error.message : String(error));
|
|
133
|
+
onDone?.(job);
|
|
134
|
+
});
|
|
135
|
+
return job;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function persistDiscovery(job) {
|
|
139
|
+
const results = discoveryResults(job);
|
|
140
|
+
store.saveRun({
|
|
141
|
+
id: job.id,
|
|
142
|
+
kind: "discover",
|
|
143
|
+
name: job.meta.name,
|
|
144
|
+
keywords: job.meta.keywords,
|
|
145
|
+
filters: job.meta.filters,
|
|
146
|
+
started_at: new Date(job.startedAt).toISOString(),
|
|
147
|
+
finished_at: new Date().toISOString(),
|
|
148
|
+
status: job.status,
|
|
149
|
+
accounts_passed: results.passed_accounts.length,
|
|
150
|
+
accounts_measured: results.measured_accounts.length,
|
|
151
|
+
results,
|
|
152
|
+
});
|
|
153
|
+
const passed = job.state.measured.filter((a) => a.verdict?.passed).map(withoutSamples);
|
|
154
|
+
if (passed.length) store.fileAccounts(job.id, job.meta.name, passed);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
const mcp = new McpServer({ name: NAME, version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
160
|
+
|
|
161
|
+
mcp.registerTool(
|
|
162
|
+
"status",
|
|
163
|
+
{
|
|
164
|
+
title: "Is anything ready or running",
|
|
165
|
+
description: [
|
|
166
|
+
"Says whether this machine can run anything and what is already in flight:",
|
|
167
|
+
"whether Chrome is installed, whether a TikTok session is saved here, which",
|
|
168
|
+
"runs are working right now, and how big the research library has grown.",
|
|
169
|
+
"",
|
|
170
|
+
"Call this first, every session. Both of the first two gate every browser tool,",
|
|
171
|
+
"and neither can be arranged from inside a run.",
|
|
172
|
+
].join("\n"),
|
|
173
|
+
inputSchema: {},
|
|
174
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
175
|
+
},
|
|
176
|
+
async () => {
|
|
177
|
+
const library = store.searchAccounts({ limit: 0 });
|
|
178
|
+
return text({
|
|
179
|
+
chrome_installed: chromeUnavailable() === null,
|
|
180
|
+
chrome_problem: chromeUnavailable(),
|
|
181
|
+
tiktok_session: chrome.isLoggedIn(),
|
|
182
|
+
profile_dir: PROFILE_DIR,
|
|
183
|
+
downloads_dir: DOWNLOADS_DIR,
|
|
184
|
+
running: jobsLib.inFlight().map((job) => ({
|
|
185
|
+
job_id: job.id,
|
|
186
|
+
tool: job.tool,
|
|
187
|
+
status: job.status,
|
|
188
|
+
elapsed: jobsLib.humanElapsed(job),
|
|
189
|
+
alert: job.alert?.kind ?? null,
|
|
190
|
+
})),
|
|
191
|
+
library_accounts: library.total,
|
|
192
|
+
runs_recorded: store.listRuns().length,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
mcp.registerTool(
|
|
198
|
+
"login_tiktok",
|
|
199
|
+
{
|
|
200
|
+
title: "Log in to TikTok",
|
|
201
|
+
description: [
|
|
202
|
+
"Opens a real Chrome window on TikTok's login page and waits for the user to",
|
|
203
|
+
"sign in. The session is saved to a browser profile on this machine and every",
|
|
204
|
+
"other tool reads TikTok as that user from then on.",
|
|
205
|
+
"",
|
|
206
|
+
"This blocks while the window is open, so tell the user to go and log in before",
|
|
207
|
+
"you call it, and expect to wait. It answers as soon as the session cookie lands.",
|
|
208
|
+
].join("\n"),
|
|
209
|
+
inputSchema: {
|
|
210
|
+
timeout_seconds: z
|
|
211
|
+
.number()
|
|
212
|
+
.int()
|
|
213
|
+
.min(30)
|
|
214
|
+
.max(600)
|
|
215
|
+
.optional()
|
|
216
|
+
.describe("How long to hold the window open. Defaults to 180."),
|
|
217
|
+
},
|
|
218
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
219
|
+
},
|
|
220
|
+
async ({ timeout_seconds }) => {
|
|
221
|
+
const missingChrome = chromeUnavailable();
|
|
222
|
+
if (missingChrome) return fail(missingChrome);
|
|
223
|
+
if (chrome.isLoggedIn()) return text("A TikTok session is already saved on this machine.");
|
|
224
|
+
const { loggedIn } = await chrome.login((timeout_seconds ?? 180) * 1000);
|
|
225
|
+
return text(
|
|
226
|
+
loggedIn
|
|
227
|
+
? "Logged in. The session is saved and every tool can read TikTok now."
|
|
228
|
+
: "The window closed without a session appearing. Call login_tiktok again when the user is ready."
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
mcp.registerTool(
|
|
234
|
+
"start_discovery",
|
|
235
|
+
{
|
|
236
|
+
title: "Find new accounts",
|
|
237
|
+
description: [
|
|
238
|
+
"Searches TikTok for accounts posting photo slideshows about the keywords given,",
|
|
239
|
+
"then visits each candidate profile and measures it: how much of what they post",
|
|
240
|
+
"is slideshows, how many views those get, how often they post, how steady it is.",
|
|
241
|
+
"Accounts that clear the filters are the result.",
|
|
242
|
+
"",
|
|
243
|
+
"This drives a real Chrome window on the user's machine and takes minutes, not",
|
|
244
|
+
"seconds. It returns a job_id straight away; await_job follows it and job_results",
|
|
245
|
+
"reads what it found, mid-run as well as at the end.",
|
|
246
|
+
"",
|
|
247
|
+
"Everything that passes is filed into the user's local library automatically, so",
|
|
248
|
+
"once the run is done the same accounts turn up in search_accounts and",
|
|
249
|
+
"get_account, with the run listed by list_runs.",
|
|
250
|
+
"",
|
|
251
|
+
"Keywords are the whole of the search: pick several phrasings of the thing being",
|
|
252
|
+
"looked for rather than one, since each is searched in turn until enough accounts",
|
|
253
|
+
"have passed. The filters are cheap to loosen and expensive to get wrong, so start",
|
|
254
|
+
"near the defaults unless the user asked otherwise.",
|
|
255
|
+
].join("\n"),
|
|
256
|
+
inputSchema: {
|
|
257
|
+
keywords: z
|
|
258
|
+
.array(z.string().min(1))
|
|
259
|
+
.min(1)
|
|
260
|
+
.describe("What to search TikTok for. Several phrasings work better than one."),
|
|
261
|
+
name: z.string().optional().describe("What to call this run in the library. Defaults to a dated name."),
|
|
262
|
+
max_accounts: z.number().int().min(1).optional().describe("How many accounts to aim for. Defaults to 20."),
|
|
263
|
+
min_followers: z.number().int().min(0).optional().describe("Drop accounts under this many followers. Defaults to 0, which keeps all."),
|
|
264
|
+
min_slideshow_pct: z
|
|
265
|
+
.number()
|
|
266
|
+
.min(0)
|
|
267
|
+
.max(100)
|
|
268
|
+
.optional()
|
|
269
|
+
.describe("Drop accounts whose posts are less than this percent photo slideshows. Defaults to 50."),
|
|
270
|
+
avg_views_days: z.number().int().min(1).optional().describe("The window the view averages are measured over, in days. Defaults to 30."),
|
|
271
|
+
min_avg_views: z.number().int().min(0).optional().describe("Drop accounts averaging fewer views than this over that window. Defaults to 0."),
|
|
272
|
+
min_total_views: z
|
|
273
|
+
.number()
|
|
274
|
+
.int()
|
|
275
|
+
.min(0)
|
|
276
|
+
.optional()
|
|
277
|
+
.describe(
|
|
278
|
+
"Drop accounts with fewer total views than this over that window. Defaults to 100000, the working floor for a healthy niche; pass 0 to turn the floor off."
|
|
279
|
+
),
|
|
280
|
+
hashtag_pivot: z
|
|
281
|
+
.boolean()
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("Search again on the hashtags the first pass turned up. Reaches accounts the original wording never would. Defaults to false."),
|
|
284
|
+
},
|
|
285
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
286
|
+
},
|
|
287
|
+
async (input) => {
|
|
288
|
+
const blocked = notReady() ?? busy("discover", "discovery");
|
|
289
|
+
if (blocked) return blocked;
|
|
290
|
+
const keywords = input.keywords.map((word) => word.trim()).filter(Boolean);
|
|
291
|
+
if (keywords.length === 0) return fail("Every keyword was blank. Give at least one thing to search TikTok for.");
|
|
292
|
+
|
|
293
|
+
const job = jobsLib.createJob("discover", {
|
|
294
|
+
name: input.name?.trim() || `run-${new Date().toISOString().slice(0, 10)}`,
|
|
295
|
+
keywords,
|
|
296
|
+
maxAccounts: input.max_accounts ?? 20,
|
|
297
|
+
hashtagPivot: input.hashtag_pivot ?? false,
|
|
298
|
+
filters: {
|
|
299
|
+
minFollowers: input.min_followers ?? 0,
|
|
300
|
+
minRatio: (input.min_slideshow_pct ?? 50) / 100,
|
|
301
|
+
avgViewsDays: input.avg_views_days ?? 30,
|
|
302
|
+
minAvgViews: input.min_avg_views ?? 0,
|
|
303
|
+
// 100k, not 0: a caller that leaves this unset still gets the working
|
|
304
|
+
// floor. Turning it off is a decision, made by passing 0 explicitly.
|
|
305
|
+
minTotalViews: input.min_total_views ?? 100_000,
|
|
306
|
+
},
|
|
307
|
+
tuning: [],
|
|
308
|
+
});
|
|
309
|
+
launch(job, runDiscovery, persistDiscovery);
|
|
310
|
+
return started(
|
|
311
|
+
job,
|
|
312
|
+
[
|
|
313
|
+
`Searching TikTok for ${keywords.length} keyword(s), then visiting each candidate`,
|
|
314
|
+
"profile one at a time to measure it. Both halves are a browser loading pages,",
|
|
315
|
+
"so this is the slowest tool here.",
|
|
316
|
+
"",
|
|
317
|
+
"Whatever passes the filters is filed into the user's library at the end.",
|
|
318
|
+
].join("\n")
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
mcp.registerTool(
|
|
324
|
+
"analyze_account",
|
|
325
|
+
{
|
|
326
|
+
title: "Measure one account",
|
|
327
|
+
description: [
|
|
328
|
+
"Reads one TikTok account's posts and works out what they are doing: the share of",
|
|
329
|
+
"posts that are photo slideshows, average views, likes, comments, shares and saves,",
|
|
330
|
+
"how often they post, how steady it is, their top hashtags, and their best posts",
|
|
331
|
+
"over the window.",
|
|
332
|
+
"",
|
|
333
|
+
"top_posts is ordered by views, highest first. Slideshows and videos are ranked",
|
|
334
|
+
"together in one list, each entry saying which it is. Only the slideshow entries",
|
|
335
|
+
"can be downloaded, and their url is exactly what start_download takes as a link.",
|
|
336
|
+
"",
|
|
337
|
+
"This drives a real Chrome window and takes a few minutes. It returns a job_id",
|
|
338
|
+
"straight away; await_job follows it and job_results has the report.",
|
|
339
|
+
"",
|
|
340
|
+
"This is a fresh read of TikTok, not a library lookup. If the account is already",
|
|
341
|
+
"in the library, get_account answers at once and costs nobody anything; use this",
|
|
342
|
+
"when the account is new, or when the numbers need to be current.",
|
|
343
|
+
"",
|
|
344
|
+
"Nothing is written to the library by this tool.",
|
|
345
|
+
].join("\n"),
|
|
346
|
+
inputSchema: {
|
|
347
|
+
username: z.string().min(1).describe("The TikTok handle, with or without the leading @."),
|
|
348
|
+
days: z.number().int().min(1).optional().describe("How far back to read, in days. Defaults to 30. Ignored when all_videos is true."),
|
|
349
|
+
all_videos: z
|
|
350
|
+
.boolean()
|
|
351
|
+
.optional()
|
|
352
|
+
.describe("Read the whole account rather than a window. Takes far longer on a big account. Defaults to false."),
|
|
353
|
+
},
|
|
354
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
355
|
+
},
|
|
356
|
+
async ({ username, days, all_videos }) => {
|
|
357
|
+
const blocked = notReady() ?? busy("profile", "single-account");
|
|
358
|
+
if (blocked) return blocked;
|
|
359
|
+
const handle = username.trim().replace(/^@+/, "");
|
|
360
|
+
if (!handle) return fail("That username was empty once the @ was taken off.");
|
|
361
|
+
const job = jobsLib.createJob("profile", {
|
|
362
|
+
username: handle,
|
|
363
|
+
days: days ?? 30,
|
|
364
|
+
allVideos: all_videos ?? false,
|
|
365
|
+
});
|
|
366
|
+
launch(job, runAnalysis);
|
|
367
|
+
return started(
|
|
368
|
+
job,
|
|
369
|
+
`Opening @${handle} and scrolling back through ${
|
|
370
|
+
all_videos ? "their whole account" : `the last ${days ?? 30} days`
|
|
371
|
+
} to read every post it can, then measuring what it read.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
mcp.registerTool(
|
|
377
|
+
"start_download",
|
|
378
|
+
{
|
|
379
|
+
title: "Download slideshows",
|
|
380
|
+
description: [
|
|
381
|
+
"Downloads photo slideshows to a folder on the user's disk. Give it either",
|
|
382
|
+
"accounts or links; both download the same thing and differ only in how the",
|
|
383
|
+
"targets are named. Give one or the other, never both.",
|
|
384
|
+
"",
|
|
385
|
+
"What comes down is evidence for a teardown, not material to publish. Study the",
|
|
386
|
+
"structure, then rebuild it in the user's own words and images. Never hand a",
|
|
387
|
+
"pulled image back as something to post.",
|
|
388
|
+
"",
|
|
389
|
+
"accounts takes handles and lets this tool choose which of their slideshows to",
|
|
390
|
+
"pull. links takes the URLs of particular posts and pulls exactly those.",
|
|
391
|
+
"",
|
|
392
|
+
"An account download is three choices. Leave all three alone and it takes the",
|
|
393
|
+
`${DOWNLOAD_DEFAULTS.count} most viewed slideshows of the account, of any age. count, sort and`,
|
|
394
|
+
"max_days at the top level move those defaults for every account in the call, and",
|
|
395
|
+
"any account may carry its own instead.",
|
|
396
|
+
"",
|
|
397
|
+
"The order of the work is: read the account's posts, drop anything older than",
|
|
398
|
+
"max_days, sort what is left by sort, take the first count of them, download those.",
|
|
399
|
+
"So max_days is the window the ranking happens inside rather than a filter applied",
|
|
400
|
+
"afterwards, and 0 means no window at all.",
|
|
401
|
+
"",
|
|
402
|
+
"views, likes, comments, shares and favorites each rank on that number as TikTok",
|
|
403
|
+
"reports it. engagement ranks on (likes + comments + shares + favorites) divided",
|
|
404
|
+
"by views, which favours posts that got a reaction over posts that merely got seen.",
|
|
405
|
+
"Those six are the whole list. Every slideshow arrives with a metadata.json beside",
|
|
406
|
+
"its images carrying views, likes, comments, shares, favorites, engagement_rate,",
|
|
407
|
+
"slide_count, hashtags and published_at, so a ranking this tool does not offer can",
|
|
408
|
+
"be worked out from what came down.",
|
|
409
|
+
"",
|
|
410
|
+
"include_videos pulls videos as well, and each one arrives with sheet.png beside",
|
|
411
|
+
"it: single frames taken at 0, 1, 2, 5 and 10 seconds, left to right, tiled into",
|
|
412
|
+
"one image. That is there because nothing can watch a video, and the first",
|
|
413
|
+
"seconds are what decide whether anyone else does either. Read the sheet to",
|
|
414
|
+
"describe the structure - the cover frame, what the opening card says, when the",
|
|
415
|
+
"first cut lands. sheet_offsets moves those times. It needs ffmpeg on the machine;",
|
|
416
|
+
"without it the video still lands and the sheet does not.",
|
|
417
|
+
"",
|
|
418
|
+
"It returns a job_id straight away. The files stay on disk under the downloads",
|
|
419
|
+
"directory that status reports; nothing is cleaned up behind the user.",
|
|
420
|
+
].join("\n"),
|
|
421
|
+
inputSchema: {
|
|
422
|
+
accounts: z
|
|
423
|
+
.array(
|
|
424
|
+
z.object({
|
|
425
|
+
username: z.string().min(1),
|
|
426
|
+
count: z.number().int().min(1).max(MAX_DOWNLOAD_COUNT).optional(),
|
|
427
|
+
sort: z.enum(SORT_VALUES).optional(),
|
|
428
|
+
max_days: z.number().int().min(0).optional(),
|
|
429
|
+
})
|
|
430
|
+
)
|
|
431
|
+
.optional()
|
|
432
|
+
.describe("Handles to pull from, each optionally carrying its own count, sort and max_days."),
|
|
433
|
+
links: z
|
|
434
|
+
.array(z.string().url())
|
|
435
|
+
.optional()
|
|
436
|
+
.describe("URLs of particular posts, as https://www.tiktok.com/@handle/photo/..."),
|
|
437
|
+
count: z.number().int().min(1).max(MAX_DOWNLOAD_COUNT).optional().describe(`Slideshows per account. Defaults to ${DOWNLOAD_DEFAULTS.count}.`),
|
|
438
|
+
include_videos: z.boolean().optional().describe("Pull videos too, each with a sheet of frames for reading its structure. Off by default."),
|
|
439
|
+
sheet_offsets: z.array(z.number().min(0)).min(1).max(12).optional().describe("Seconds to take frames at. Defaults to 0, 1, 2, 5 and 10 - the cover frame and the window where a viewer decides."),
|
|
440
|
+
sort: z.enum(SORT_VALUES).optional().describe(`What counts as best. Defaults to ${DOWNLOAD_DEFAULTS.sort}.`),
|
|
441
|
+
max_days: z.number().int().min(0).optional().describe("Ignore slideshows older than this before ranking. 0, the default, means no window."),
|
|
442
|
+
},
|
|
443
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
444
|
+
},
|
|
445
|
+
async (input) => {
|
|
446
|
+
const blocked = notReady() ?? busy("download", "download");
|
|
447
|
+
if (blocked) return blocked;
|
|
448
|
+
const hasAccounts = Boolean(input.accounts?.length);
|
|
449
|
+
const hasLinks = Boolean(input.links?.length);
|
|
450
|
+
if (hasAccounts === hasLinks) {
|
|
451
|
+
return fail("Give either accounts or links, never both and never neither.");
|
|
452
|
+
}
|
|
453
|
+
const job = jobsLib.createJob("download", {
|
|
454
|
+
accounts: input.accounts ?? [],
|
|
455
|
+
links: input.links ?? [],
|
|
456
|
+
count: input.count ?? DOWNLOAD_DEFAULTS.count,
|
|
457
|
+
sort: input.sort ?? DOWNLOAD_DEFAULTS.sort,
|
|
458
|
+
maxDays: input.max_days ?? DOWNLOAD_DEFAULTS.maxDays,
|
|
459
|
+
includeVideos: input.include_videos ?? false,
|
|
460
|
+
sheetOffsets: input.sheet_offsets,
|
|
461
|
+
});
|
|
462
|
+
launch(job, runDownload);
|
|
463
|
+
return started(
|
|
464
|
+
job,
|
|
465
|
+
hasLinks
|
|
466
|
+
? `Pulling ${input.links.length} post(s) one at a time.`
|
|
467
|
+
: `Reading ${input.accounts.length} account(s), then pulling their slideshows.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
mcp.registerTool(
|
|
473
|
+
"start_ad_harvest",
|
|
474
|
+
{
|
|
475
|
+
title: "Harvest the public ad library",
|
|
476
|
+
description: [
|
|
477
|
+
"Collects the ads currently running in a niche from Meta's public Ad Library,",
|
|
478
|
+
"which covers Facebook and Instagram. This is the other half of the research:",
|
|
479
|
+
"the organic side shows what people choose to watch, this shows what somebody is",
|
|
480
|
+
"paying to put in front of them.",
|
|
481
|
+
"",
|
|
482
|
+
"Give a country and at least one query. A keyword discovers who advertises in a",
|
|
483
|
+
"niche and is noisy; a brand name is the clean seam and is what to use once you",
|
|
484
|
+
"know who the competitors are.",
|
|
485
|
+
"",
|
|
486
|
+
"Days live is the only performance signal the library exposes outside EU political",
|
|
487
|
+
"ads: there is no spend, no impressions and no click-through rate. A long-running",
|
|
488
|
+
"ad means somebody keeps paying for it, which is evidence of survival and not of",
|
|
489
|
+
"efficiency. Never describe a find here as best-performing.",
|
|
490
|
+
"",
|
|
491
|
+
"It needs no login and does not touch the signed-in research profile. It returns a",
|
|
492
|
+
"job_id straight away; the full harvest is written to disk and the reply carries",
|
|
493
|
+
"the longest-running forty with an archetype guessed from the copy where one is",
|
|
494
|
+
"obvious. Those guesses are suggestions to check against the creative, never facts.",
|
|
495
|
+
].join("\n"),
|
|
496
|
+
inputSchema: {
|
|
497
|
+
country: z.string().length(2).describe("ISO 3166 alpha-2, e.g. SE, US, GB. The library is searched per country."),
|
|
498
|
+
queries: z.array(z.string().min(2)).min(1).max(40)
|
|
499
|
+
.describe("Keywords or brand names. Brand names give the cleanest read."),
|
|
500
|
+
},
|
|
501
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
502
|
+
},
|
|
503
|
+
async ({ country, queries }) => {
|
|
504
|
+
const missingChrome = chromeUnavailable();
|
|
505
|
+
if (missingChrome) return fail(missingChrome);
|
|
506
|
+
const blocked = busy("ads", "ad harvest");
|
|
507
|
+
if (blocked) return blocked;
|
|
508
|
+
const job = jobsLib.createJob("ads", { country: country.toUpperCase(), queries });
|
|
509
|
+
launch(job, runAdHarvest);
|
|
510
|
+
return started(
|
|
511
|
+
job,
|
|
512
|
+
`Searching the ${country.toUpperCase()} ad library for ${queries.length} term(s), one at a time.`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
mcp.registerTool(
|
|
518
|
+
"job_status",
|
|
519
|
+
{
|
|
520
|
+
title: "Check a run",
|
|
521
|
+
description: [
|
|
522
|
+
"Says where a run has got to: whether it is still going, how long it has been at",
|
|
523
|
+
"it, the last few lines it printed, anything it is waiting on, and what to do next.",
|
|
524
|
+
"Reads state already held, so it answers at once and costs nothing.",
|
|
525
|
+
"",
|
|
526
|
+
"This is a snapshot. To wait for the run rather than keep asking, use await_job.",
|
|
527
|
+
"next_step says what the status means and what to do about it; follow it rather",
|
|
528
|
+
"than deciding from the status word alone.",
|
|
529
|
+
].join("\n"),
|
|
530
|
+
inputSchema: { job_id: z.string().describe("From start_discovery, analyze_account or start_download.") },
|
|
531
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
532
|
+
},
|
|
533
|
+
async ({ job_id }) => {
|
|
534
|
+
const job = jobsLib.findJob(job_id);
|
|
535
|
+
if (!job) return unknownJob(job_id);
|
|
536
|
+
return text({ ...jobsLib.baseStatus(job), next_step: jobsLib.nextStep(job) });
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
mcp.registerTool(
|
|
541
|
+
"await_job",
|
|
542
|
+
{
|
|
543
|
+
title: "Wait for a run",
|
|
544
|
+
description: [
|
|
545
|
+
"Waits for a run and comes back the moment it does something worth hearing about,",
|
|
546
|
+
"so a finish or a challenge is known at once rather than whenever the next poll",
|
|
547
|
+
"happens to land. This is the way to follow a run; job_status is for a snapshot.",
|
|
548
|
+
"",
|
|
549
|
+
"It blocks until the first of these, then returns: the run settles (done, error or",
|
|
550
|
+
"stopped), a challenge appears that only a person can clear, the run is paused or",
|
|
551
|
+
"resumed, or the wait times out. event says which, and next_step says what to do.",
|
|
552
|
+
"If the run has already settled when this is called, it returns straight away.",
|
|
553
|
+
"",
|
|
554
|
+
"wake_on decides how light a sleeper this is. Left at done it wakes only for those",
|
|
555
|
+
"run-ending or person-needing moments. Set to events it also wakes on a discovery",
|
|
556
|
+
"run for the smaller things worth a look while it works: a new account passing, an",
|
|
557
|
+
"outlier or near-miss mark newly assigned, the keywords running out, the projected",
|
|
558
|
+
"finish climbing sharply. When it wakes on those, event is events, wake_events lists",
|
|
559
|
+
"what fired, and summary carries the same picture job_results gives, narrowed to",
|
|
560
|
+
"what changed since this wait began. That is the loop: start the run, await with",
|
|
561
|
+
"wake_on events, read the summary when woken, and either keep awaiting or tune_run",
|
|
562
|
+
"if the numbers say the question was wrong.",
|
|
563
|
+
"",
|
|
564
|
+
"A timeout is not an error: it means nothing happened in the window and the run is",
|
|
565
|
+
"still going. Call await_job again to keep waiting. The wait is long by default and",
|
|
566
|
+
"can be told to be longer, so a single call is usually enough.",
|
|
567
|
+
].join("\n"),
|
|
568
|
+
inputSchema: {
|
|
569
|
+
job_id: z.string().describe("From start_discovery, analyze_account or start_download."),
|
|
570
|
+
timeout_seconds: z
|
|
571
|
+
.number()
|
|
572
|
+
.min(jobsLib.AWAIT_MIN_SECONDS)
|
|
573
|
+
.max(jobsLib.AWAIT_MAX_SECONDS)
|
|
574
|
+
.optional()
|
|
575
|
+
.describe(
|
|
576
|
+
`How long to wait before returning with event timeout. Defaults to ${jobsLib.AWAIT_DEFAULT_SECONDS}, from ${jobsLib.AWAIT_MIN_SECONDS} to ${jobsLib.AWAIT_MAX_SECONDS}. A timeout just means call again; it does not end the run.`
|
|
577
|
+
),
|
|
578
|
+
wake_on: z
|
|
579
|
+
.enum(["done", "events"])
|
|
580
|
+
.optional()
|
|
581
|
+
.describe(
|
|
582
|
+
"done, the default, wakes only when the run ends or needs a person. events also wakes a discovery run on a new pass, a new mark, keywords running out, or the projection climbing sharply."
|
|
583
|
+
),
|
|
584
|
+
},
|
|
585
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
586
|
+
},
|
|
587
|
+
async ({ job_id, timeout_seconds, wake_on }, extra) => {
|
|
588
|
+
const job = jobsLib.findJob(job_id);
|
|
589
|
+
if (!job) return unknownJob(job_id);
|
|
590
|
+
if (!jobsLib.IN_FLIGHT.has(job.status)) return text(jobsLib.awaitResult(job, "settled"));
|
|
591
|
+
if (job.alert && jobsLib.surfacedAlert.get(job.id) !== job.alert) {
|
|
592
|
+
return text(jobsLib.awaitResult(job, "alert"));
|
|
593
|
+
}
|
|
594
|
+
const timeout = Math.min(
|
|
595
|
+
Math.max(timeout_seconds ?? jobsLib.AWAIT_DEFAULT_SECONDS, jobsLib.AWAIT_MIN_SECONDS),
|
|
596
|
+
jobsLib.AWAIT_MAX_SECONDS
|
|
597
|
+
);
|
|
598
|
+
const result = await jobsLib.waitForJob(job, timeout, extra, wake_on ?? "done", foundAccount);
|
|
599
|
+
return text(result);
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
mcp.registerTool(
|
|
604
|
+
"job_results",
|
|
605
|
+
{
|
|
606
|
+
title: "Read a run",
|
|
607
|
+
description: [
|
|
608
|
+
"Returns what a run has produced. The shape follows the tool: a discovery run",
|
|
609
|
+
"gives its live vitals, the accounts that passed, every profile it measured and",
|
|
610
|
+
"rejected with the reason, the rejections counted by reason, and the outliers it",
|
|
611
|
+
"flagged; an account analysis gives one report; a download gives what came down",
|
|
612
|
+
"and where it landed.",
|
|
613
|
+
"",
|
|
614
|
+
"Discovery answers while it is still running, so this is how to watch a run take",
|
|
615
|
+
"shape: nothing it has measured is hidden, a rejected account rides here as a",
|
|
616
|
+
"compact row saying by how much it fell short, and no_posts_in_window is kept",
|
|
617
|
+
"distinct from a real zero. An account analysis or download has nothing to read",
|
|
618
|
+
"until it reaches done.",
|
|
619
|
+
"",
|
|
620
|
+
"The passed accounts are also in the library once the run finishes, so",
|
|
621
|
+
"search_accounts and get_account answer follow-up questions about them, and",
|
|
622
|
+
"get_account has the bio and captions a compact row leaves out.",
|
|
623
|
+
].join("\n"),
|
|
624
|
+
inputSchema: { job_id: z.string().describe("From any of the run tools.") },
|
|
625
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
626
|
+
},
|
|
627
|
+
async ({ job_id }) => {
|
|
628
|
+
const job = jobsLib.findJob(job_id);
|
|
629
|
+
if (job) {
|
|
630
|
+
if (job.tool === "discover") return text(discoveryResults(job));
|
|
631
|
+
if (job.status === "error") return fail(`That run failed: ${job.error}`);
|
|
632
|
+
if (!job.result) return text(`Nothing to read yet: the run is ${job.status}.`);
|
|
633
|
+
return text(job.result);
|
|
634
|
+
}
|
|
635
|
+
const saved = store.loadRun(job_id);
|
|
636
|
+
if (saved) return text(saved.results ?? saved);
|
|
637
|
+
return unknownJob(job_id);
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
mcp.registerTool(
|
|
642
|
+
"job_control",
|
|
643
|
+
{
|
|
644
|
+
title: "Pause, resume or stop a run",
|
|
645
|
+
description: [
|
|
646
|
+
"The three buttons a person would have: pause holds the run where it is, resume",
|
|
647
|
+
"picks it back up, stop ends it for good.",
|
|
648
|
+
"",
|
|
649
|
+
"Resume is what clears a challenge: once the user has solved the verification in",
|
|
650
|
+
"the Chrome window, resume carries the run to the end with no second prompt.",
|
|
651
|
+
"",
|
|
652
|
+
"Stopping cannot be undone, so never stop a run the user did not ask you to stop.",
|
|
653
|
+
"Everything a run measured before it ended is kept either way, and job_results",
|
|
654
|
+
"still reads it back.",
|
|
655
|
+
].join("\n"),
|
|
656
|
+
inputSchema: {
|
|
657
|
+
job_id: z.string(),
|
|
658
|
+
action: z.enum(["pause", "resume", "stop"]),
|
|
659
|
+
},
|
|
660
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
661
|
+
},
|
|
662
|
+
async ({ job_id, action }) => {
|
|
663
|
+
const job = jobsLib.findJob(job_id);
|
|
664
|
+
if (!job) return unknownJob(job_id);
|
|
665
|
+
const outcome = jobsLib.control(job, action);
|
|
666
|
+
if (!outcome.ok) return fail(outcome.reason);
|
|
667
|
+
return text({ job_id, action, status: job.status, next_step: jobsLib.nextStep(job) });
|
|
668
|
+
}
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
mcp.registerTool(
|
|
672
|
+
"tune_run",
|
|
673
|
+
{
|
|
674
|
+
title: "Retune a discovery in flight",
|
|
675
|
+
description: [
|
|
676
|
+
"Changes a discovery run's filters, window, target or keywords while it is still",
|
|
677
|
+
"going, and re-judges everything already measured on the spot. Nothing is browsed",
|
|
678
|
+
"again: the run holds every account it measured with the raw posts behind it, so a",
|
|
679
|
+
"filter change is answered in memory and at once.",
|
|
680
|
+
"",
|
|
681
|
+
"Every account marked as failing is re-decided against the new numbers instantly;",
|
|
682
|
+
"the ones that now clear the bar join the passing set, the ones that no longer",
|
|
683
|
+
"clear it fall back out, and the vitals, marks and breakdown are rebuilt. Changing",
|
|
684
|
+
"the window is exact too, recomputed from each account's own retained posts.",
|
|
685
|
+
"",
|
|
686
|
+
"From there the run carries the new numbers forward: every profile it measures next",
|
|
687
|
+
"is judged by them, the stop condition uses the new target, and appended keywords",
|
|
688
|
+
"extend what the search still has to draw on. Keywords are added to the end of the",
|
|
689
|
+
"list, never removed from it.",
|
|
690
|
+
"",
|
|
691
|
+
"The answer is the delta it caused, and the delta is the reading. Loosening a floor",
|
|
692
|
+
"and recovering a crowd of accounts says the floor was the problem. Recovering",
|
|
693
|
+
"nothing says the niche is genuinely thin here, which is the moment for fresh",
|
|
694
|
+
"keywords rather than a lower bar.",
|
|
695
|
+
].join("\n"),
|
|
696
|
+
inputSchema: {
|
|
697
|
+
job_id: z.string().describe("A discovery run, in flight."),
|
|
698
|
+
min_followers: z.number().int().min(0).optional(),
|
|
699
|
+
min_slideshow_pct: z.number().min(0).max(100).optional(),
|
|
700
|
+
avg_views_days: z.number().int().min(1).optional(),
|
|
701
|
+
min_avg_views: z.number().int().min(0).optional(),
|
|
702
|
+
min_total_views: z.number().int().min(0).optional(),
|
|
703
|
+
max_accounts: z.number().int().min(1).optional().describe("Move the target the run is working towards."),
|
|
704
|
+
add_keywords: z.array(z.string().min(1)).optional().describe("Appended to the end of the keyword list."),
|
|
705
|
+
},
|
|
706
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
707
|
+
},
|
|
708
|
+
async (input) => {
|
|
709
|
+
const job = jobsLib.findJob(input.job_id);
|
|
710
|
+
if (!job) return unknownJob(input.job_id);
|
|
711
|
+
if (job.tool !== "discover") return fail("tune_run only applies to a discovery run.");
|
|
712
|
+
|
|
713
|
+
const before = job.state.measured.filter((a) => a.verdict?.passed).map((a) => a.username);
|
|
714
|
+
const filters = { ...job.meta.filters };
|
|
715
|
+
if (input.min_followers !== undefined) filters.minFollowers = input.min_followers;
|
|
716
|
+
if (input.min_slideshow_pct !== undefined) filters.minRatio = input.min_slideshow_pct / 100;
|
|
717
|
+
if (input.avg_views_days !== undefined) filters.avgViewsDays = input.avg_views_days;
|
|
718
|
+
if (input.min_avg_views !== undefined) filters.minAvgViews = input.min_avg_views;
|
|
719
|
+
if (input.min_total_views !== undefined) filters.minTotalViews = input.min_total_views;
|
|
720
|
+
job.meta.filters = filters;
|
|
721
|
+
|
|
722
|
+
if (input.max_accounts !== undefined) {
|
|
723
|
+
job.meta.maxAccounts = input.max_accounts;
|
|
724
|
+
job.state.target = input.max_accounts;
|
|
725
|
+
}
|
|
726
|
+
if (input.add_keywords?.length) {
|
|
727
|
+
const added = input.add_keywords.map((k) => k.trim()).filter(Boolean);
|
|
728
|
+
job.meta.keywords.push(...added);
|
|
729
|
+
job.state.keywordsTotal = job.meta.keywords.length;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Re-judge from each account's own retained posts, not from an aggregate.
|
|
733
|
+
for (const account of job.state.measured) {
|
|
734
|
+
if (account.verdict?.unmeasured) continue;
|
|
735
|
+
const redone = reevaluate(account, filters);
|
|
736
|
+
account.verdict = redone.verdict;
|
|
737
|
+
account.metrics = {
|
|
738
|
+
...account.metrics,
|
|
739
|
+
slideshow_ratio: redone.ratioPct,
|
|
740
|
+
[`avg_views_last_${filters.avgViewsDays}d`]: redone.noPostsInWindow
|
|
741
|
+
? null
|
|
742
|
+
: Math.trunc(redone.avgViews ?? 0),
|
|
743
|
+
[`total_views_last_${filters.avgViewsDays}d`]: redone.noPostsInWindow
|
|
744
|
+
? null
|
|
745
|
+
: Math.trunc(redone.totalViewsWindow),
|
|
746
|
+
view_follower_ratio: redone.verdict.viewsPerFollower,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
flagOutliers(job.state.measured);
|
|
750
|
+
|
|
751
|
+
const after = job.state.measured.filter((a) => a.verdict?.passed).map((a) => a.username);
|
|
752
|
+
const recovered = after.filter((name) => !before.includes(name));
|
|
753
|
+
const lost = before.filter((name) => !after.includes(name));
|
|
754
|
+
job.meta.tuning.push({
|
|
755
|
+
at: new Date().toISOString(),
|
|
756
|
+
changes: input,
|
|
757
|
+
recovered: recovered.length,
|
|
758
|
+
lost: lost.length,
|
|
759
|
+
});
|
|
760
|
+
jobsLib.log(job, `Retuned: ${recovered.length} recovered, ${lost.length} no longer passing.`);
|
|
761
|
+
jobsLib.emit("progress", job);
|
|
762
|
+
|
|
763
|
+
return text({
|
|
764
|
+
job_id: job.id,
|
|
765
|
+
filters,
|
|
766
|
+
passed_before: before.length,
|
|
767
|
+
passed_after: after.length,
|
|
768
|
+
recovered,
|
|
769
|
+
lost,
|
|
770
|
+
vitals: jobsLib.vitalsFields(jobsLib.vitals(job)),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
mcp.registerTool(
|
|
776
|
+
"list_runs",
|
|
777
|
+
{
|
|
778
|
+
title: "Past runs",
|
|
779
|
+
description: [
|
|
780
|
+
"Lists every discovery run this machine has recorded: its name, keywords, when it",
|
|
781
|
+
"ran, and how many accounts it holds. This is where run ids come from, and it",
|
|
782
|
+
"answers instantly with no browser.",
|
|
783
|
+
].join("\n"),
|
|
784
|
+
inputSchema: { limit: z.number().int().min(1).max(200).optional() },
|
|
785
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
786
|
+
},
|
|
787
|
+
async ({ limit }) => {
|
|
788
|
+
const runs = store.listRuns().slice(0, limit ?? 50);
|
|
789
|
+
return text(
|
|
790
|
+
runs.map((run) => ({
|
|
791
|
+
run_id: run.id,
|
|
792
|
+
name: run.name,
|
|
793
|
+
keywords: run.keywords,
|
|
794
|
+
started_at: run.started_at,
|
|
795
|
+
status: run.status,
|
|
796
|
+
accounts_passed: run.accounts_passed,
|
|
797
|
+
accounts_measured: run.accounts_measured,
|
|
798
|
+
}))
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
mcp.registerTool(
|
|
804
|
+
"search_accounts",
|
|
805
|
+
{
|
|
806
|
+
title: "Search the library",
|
|
807
|
+
description: [
|
|
808
|
+
"Searches every account discovery has ever filed, by free text and by run, sorted",
|
|
809
|
+
"any of seven ways, one page at a time. Instant, no browser, no risk.",
|
|
810
|
+
"",
|
|
811
|
+
"query matches handles, display names, bios and hashtags. run_id narrows to one",
|
|
812
|
+
"run. Narrow and sort rather than paging through everything.",
|
|
813
|
+
"",
|
|
814
|
+
"The library is the user's own record of research already done, so it answers",
|
|
815
|
+
"questions about accounts they already hold and can never answer a request for new",
|
|
816
|
+
"accounts: handing its contents back is returning the user their own notebook.",
|
|
817
|
+
].join("\n"),
|
|
818
|
+
inputSchema: {
|
|
819
|
+
query: z.string().optional(),
|
|
820
|
+
run_id: z.string().optional(),
|
|
821
|
+
sort: z.enum(store.SORT_ORDERS).optional().describe("Defaults to found, newest first."),
|
|
822
|
+
limit: z.number().int().min(1).max(100).optional().describe("Defaults to 25."),
|
|
823
|
+
offset: z.number().int().min(0).optional(),
|
|
824
|
+
},
|
|
825
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
826
|
+
},
|
|
827
|
+
async ({ query, run_id, sort, limit, offset }) => {
|
|
828
|
+
const found = store.searchAccounts({
|
|
829
|
+
query,
|
|
830
|
+
runId: run_id,
|
|
831
|
+
sort: sort ?? "found",
|
|
832
|
+
limit: limit ?? 25,
|
|
833
|
+
offset: offset ?? 0,
|
|
834
|
+
});
|
|
835
|
+
return text({
|
|
836
|
+
total: found.total,
|
|
837
|
+
accounts: found.accounts.map(foundAccount),
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
mcp.registerTool(
|
|
843
|
+
"get_account",
|
|
844
|
+
{
|
|
845
|
+
title: "One account in full",
|
|
846
|
+
description: [
|
|
847
|
+
"Returns everything the library holds on one account: bio, sample captions, top",
|
|
848
|
+
"hashtags, and every measurement, plus which run found it and when.",
|
|
849
|
+
"",
|
|
850
|
+
"This is the instant answer to any follow-up about an account already found. It is",
|
|
851
|
+
"a stored reading, so it is as current as the run that took it; analyze_account is",
|
|
852
|
+
"the fresh read when the numbers need to be current.",
|
|
853
|
+
].join("\n"),
|
|
854
|
+
inputSchema: { username: z.string().min(1).describe("With or without the leading @.") },
|
|
855
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
856
|
+
},
|
|
857
|
+
async ({ username }) => {
|
|
858
|
+
const account = store.getAccount(username);
|
|
859
|
+
if (!account) {
|
|
860
|
+
return fail(
|
|
861
|
+
`@${username.replace(/^@+/, "")} is not in the library. analyze_account reads it fresh from TikTok, or start_discovery finds accounts like it.`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return text({
|
|
865
|
+
...withoutSamples(account),
|
|
866
|
+
url: `https://www.tiktok.com/@${account.username}`,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
async function main() {
|
|
872
|
+
ensureDirs();
|
|
873
|
+
const transport = new StdioServerTransport();
|
|
874
|
+
await mcp.connect(transport);
|
|
875
|
+
process.stderr.write(`${NAME}: ready\n`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
main().catch((error) => {
|
|
879
|
+
process.stderr.write(`${NAME}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
880
|
+
process.exit(1);
|
|
881
|
+
});
|