@sprid/scout 0.1.0 → 0.1.2
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/CHANGELOG.md +11 -0
- package/README.md +4 -3
- package/package.json +2 -1
- package/src/discover.js +1 -41
- package/src/server.js +1 -1
- package/src/store.js +11 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2 - 2026-10-23
|
|
4
|
+
|
|
5
|
+
- `job_results` refuses a run id that is not one scout issued. A crafted id could read any JSON file on the machine.
|
|
6
|
+
|
|
7
|
+
## 0.1.1 - 2026-10-19
|
|
8
|
+
|
|
9
|
+
- Remove automatic likes from TikTok discovery. Research no longer clicks like controls while collecting accounts.
|
|
10
|
+
- Clarify local storage, requests to research platforms and optional uploads to Sprid.
|
|
11
|
+
- Report the installed package version in the MCP server handshake.
|
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@ driving your own Chrome. It finds accounts posting photo slideshows about your
|
|
|
5
5
|
keywords, measures each one, and files what it learns into a library on your
|
|
6
6
|
machine.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Research results are saved locally. Searches and page visits go to the platforms
|
|
9
|
+
you research through Chrome. Sending findings to Sprid is optional and requires
|
|
10
|
+
your instruction. Discovery does not like posts, follow accounts or send messages.
|
|
11
|
+
The Sprid plugin's `scout` skill guides the research.
|
|
11
12
|
|
|
12
13
|
## What it does
|
|
13
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprid/scout",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Local MCP server that researches a niche over your own Chrome: the accounts winning it, and the ads running in it.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://sprid.studio/agents",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
18
|
"README.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"engines": {
|
package/src/discover.js
CHANGED
|
@@ -17,7 +17,6 @@ const FIRST_ROUND_CANDIDATES_PER_ACCOUNT = 4;
|
|
|
17
17
|
const MIN_CANDIDATE_POOL = 10;
|
|
18
18
|
const MAX_CANDIDATE_POOL = 500;
|
|
19
19
|
const BOT_THRESHOLD = 3;
|
|
20
|
-
const LIKE_EVERY = 6;
|
|
21
20
|
|
|
22
21
|
function clamp(value, lo, hi) {
|
|
23
22
|
return Math.max(lo, Math.min(hi, value));
|
|
@@ -33,47 +32,16 @@ function nextPool(missing, passRate) {
|
|
|
33
32
|
return clamp(needed, MIN_CANDIDATE_POOL, MAX_CANDIDATE_POOL);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
async function likePost(context, username, postId, log) {
|
|
37
|
-
log(" Liking a post to train the algorithm...");
|
|
38
|
-
let page = null;
|
|
39
|
-
try {
|
|
40
|
-
page = await context.newPage();
|
|
41
|
-
await page.goto(`https://www.tiktok.com/@${username}/photo/${postId}`, {
|
|
42
|
-
waitUntil: "domcontentloaded",
|
|
43
|
-
timeout: 15_000,
|
|
44
|
-
});
|
|
45
|
-
await page.waitForTimeout(2000);
|
|
46
|
-
for (const selector of [
|
|
47
|
-
'[data-e2e="like-icon"]',
|
|
48
|
-
'[data-e2e="browse-like-icon"]',
|
|
49
|
-
'button[aria-label*="ike"]',
|
|
50
|
-
]) {
|
|
51
|
-
try {
|
|
52
|
-
const button = page.locator(selector).first();
|
|
53
|
-
if (await button.isVisible({ timeout: 2000 })) {
|
|
54
|
-
await button.click();
|
|
55
|
-
await page.waitForTimeout(1000);
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
} catch {}
|
|
59
|
-
}
|
|
60
|
-
} catch {
|
|
61
|
-
} finally {
|
|
62
|
-
await page?.close().catch(() => {});
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
35
|
/**
|
|
67
36
|
* Phase 1. Types each keyword into TikTok's own search and reads the results
|
|
68
37
|
* the page fetches for itself, Top tab first and Photo behind it when Top runs
|
|
69
38
|
* thin. Keywords are walked in order and spent only as far as needed.
|
|
70
39
|
*/
|
|
71
40
|
async function searchViaFeed({ keywords, cursor, maxPool, alreadyProcessed, session, log, gate, challenge }) {
|
|
72
|
-
const {
|
|
41
|
+
const { newPage } = session;
|
|
73
42
|
const searchPage = await newPage();
|
|
74
43
|
const candidates = {};
|
|
75
44
|
const hashtagFreq = {};
|
|
76
|
-
const liked = new Set();
|
|
77
45
|
let consumed = 0;
|
|
78
46
|
|
|
79
47
|
async function sweep(keyword, tab) {
|
|
@@ -157,14 +125,6 @@ async function searchViaFeed({ keywords, cursor, maxPool, alreadyProcessed, sess
|
|
|
157
125
|
}
|
|
158
126
|
}
|
|
159
127
|
|
|
160
|
-
// Tell the algorithm what this session is into, occasionally.
|
|
161
|
-
const fresh = slideshowPosts.filter((p) => !liked.has(p.post_id));
|
|
162
|
-
if (fresh.length && liked.size < Math.ceil(scroll / LIKE_EVERY) + 1) {
|
|
163
|
-
const pick = fresh[0];
|
|
164
|
-
liked.add(pick.post_id);
|
|
165
|
-
await likePost(context, pick.username, pick.post_id, log);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
128
|
if (Object.keys(candidates).length >= maxPool) break;
|
|
169
129
|
silentScrolls = Object.keys(candidates).length === prev ? silentScrolls + 1 : 0;
|
|
170
130
|
if (silentScrolls >= 3) break;
|
package/src/server.js
CHANGED
|
@@ -156,7 +156,7 @@ function persistDiscovery(job) {
|
|
|
156
156
|
|
|
157
157
|
// ---------------------------------------------------------------------------
|
|
158
158
|
|
|
159
|
-
const mcp = new McpServer({ name: NAME, version: "
|
|
159
|
+
const mcp = new McpServer({ name: NAME, version: require("../package.json").version }, { instructions: INSTRUCTIONS });
|
|
160
160
|
|
|
161
161
|
mcp.registerTool(
|
|
162
162
|
"status",
|
package/src/store.js
CHANGED
|
@@ -23,16 +23,25 @@ function readJson(file, fallback) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A run id is `${tool}-${8 hex}` (jobs.js). It arrives from a tool argument,
|
|
27
|
+
// so anything else is refused before it reaches a path: `../` in it read any
|
|
28
|
+
// JSON file on the machine.
|
|
29
|
+
const RUN_ID = /^[a-z]+-[0-9a-f]{8}$/;
|
|
30
|
+
|
|
26
31
|
function runFile(runId) {
|
|
32
|
+
if (!RUN_ID.test(String(runId))) return null;
|
|
27
33
|
return path.join(RUNS_DIR, `${runId}.json`);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
function saveRun(run) {
|
|
31
|
-
|
|
37
|
+
const file = runFile(run.id);
|
|
38
|
+
if (!file) throw new Error(`Invalid run id: ${run.id}`);
|
|
39
|
+
writeAtomic(file, run);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
function loadRun(runId) {
|
|
35
|
-
|
|
43
|
+
const file = runFile(runId);
|
|
44
|
+
return file ? readJson(file, null) : null;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
function listRuns() {
|