neuron-inspector 0.8.0 → 1.0.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/README.md +345 -42
- package/dist/automations/executor.d.ts +21 -0
- package/dist/automations/executor.js +835 -0
- package/dist/automations/executor.js.map +1 -0
- package/dist/automations/index.d.ts +7 -0
- package/dist/automations/index.js +21 -0
- package/dist/automations/index.js.map +1 -0
- package/dist/automations/store.d.ts +15 -0
- package/dist/automations/store.js +193 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/tools.d.ts +14 -0
- package/dist/automations/tools.js +735 -0
- package/dist/automations/tools.js.map +1 -0
- package/dist/automations/types.d.ts +211 -0
- package/dist/automations/types.js +4 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/business-tools.d.ts +17 -0
- package/dist/business-tools.js +3065 -0
- package/dist/business-tools.js.map +1 -0
- package/dist/collections/__tests__/assertions.test.js +203 -0
- package/dist/collections/__tests__/assertions.test.js.map +1 -0
- package/dist/collections/__tests__/dynamic-vars.test.d.ts +1 -0
- package/dist/collections/__tests__/dynamic-vars.test.js +169 -0
- package/dist/collections/__tests__/dynamic-vars.test.js.map +1 -0
- package/dist/collections/__tests__/execution.test.d.ts +1 -0
- package/dist/collections/__tests__/execution.test.js +271 -0
- package/dist/collections/__tests__/execution.test.js.map +1 -0
- package/dist/collections/__tests__/integration.test.d.ts +1 -0
- package/dist/collections/__tests__/integration.test.js +478 -0
- package/dist/collections/__tests__/integration.test.js.map +1 -0
- package/dist/collections/__tests__/resolver.test.d.ts +1 -0
- package/dist/collections/__tests__/resolver.test.js +282 -0
- package/dist/collections/__tests__/resolver.test.js.map +1 -0
- package/dist/collections/__tests__/scenario.test.d.ts +1 -0
- package/dist/collections/__tests__/scenario.test.js +367 -0
- package/dist/collections/__tests__/scenario.test.js.map +1 -0
- package/dist/collections/__tests__/scripts.test.d.ts +1 -0
- package/dist/collections/__tests__/scripts.test.js +245 -0
- package/dist/collections/__tests__/scripts.test.js.map +1 -0
- package/dist/collections/__tests__/store.test.d.ts +1 -0
- package/dist/collections/__tests__/store.test.js +313 -0
- package/dist/collections/__tests__/store.test.js.map +1 -0
- package/dist/collections/assertions.d.ts +16 -0
- package/dist/collections/assertions.js +107 -0
- package/dist/collections/assertions.js.map +1 -0
- package/dist/collections/dynamic-vars.d.ts +75 -0
- package/dist/collections/dynamic-vars.js +187 -0
- package/dist/collections/dynamic-vars.js.map +1 -0
- package/dist/collections/executor.d.ts +100 -0
- package/dist/collections/executor.js +301 -0
- package/dist/collections/executor.js.map +1 -0
- package/dist/collections/http.d.ts +9 -0
- package/dist/collections/http.js +194 -0
- package/dist/collections/http.js.map +1 -0
- package/dist/collections/index.d.ts +12 -0
- package/dist/collections/index.js +27 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/collections/postman.d.ts +10 -0
- package/dist/collections/postman.js +288 -0
- package/dist/collections/postman.js.map +1 -0
- package/dist/collections/resolver.d.ts +74 -0
- package/dist/collections/resolver.js +330 -0
- package/dist/collections/resolver.js.map +1 -0
- package/dist/collections/scripts.d.ts +20 -0
- package/dist/collections/scripts.js +225 -0
- package/dist/collections/scripts.js.map +1 -0
- package/dist/collections/store.d.ts +68 -0
- package/dist/collections/store.js +507 -0
- package/dist/collections/store.js.map +1 -0
- package/dist/collections/tools.d.ts +8 -0
- package/dist/collections/tools.js +1276 -0
- package/dist/collections/tools.js.map +1 -0
- package/dist/collections/types.d.ts +226 -0
- package/dist/collections/types.js +3 -0
- package/dist/collections/types.js.map +1 -0
- package/dist/compound-tools.js +136 -2
- package/dist/compound-tools.js.map +1 -1
- package/dist/killswitch.d.ts +8 -0
- package/dist/killswitch.js +304 -0
- package/dist/killswitch.js.map +1 -0
- package/dist/monitor.d.ts +36 -2
- package/dist/monitor.js +213 -38
- package/dist/monitor.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +39 -0
- package/dist/paths.js.map +1 -0
- package/dist/recipe-tools.js +67 -9
- package/dist/recipe-tools.js.map +1 -1
- package/dist/recipes.d.ts +6 -0
- package/dist/recipes.js +83 -8
- package/dist/recipes.js.map +1 -1
- package/dist/resilience.d.ts +112 -0
- package/dist/resilience.js +319 -0
- package/dist/resilience.js.map +1 -0
- package/dist/scheduler.js +4 -8
- package/dist/scheduler.js.map +1 -1
- package/dist/server.js +221 -60
- package/dist/server.js.map +1 -1
- package/dist/session-state.js +2 -2
- package/dist/session-state.js.map +1 -1
- package/dist/tools.js +54 -0
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/recipes/autopilot/agent.md +316 -0
- package/recipes/autopilot/learnings.md +33 -0
- package/recipes/autopilot/recipe.yaml +96 -0
- package/recipes/inbox-responder/agent.md +268 -0
- package/recipes/inbox-responder/learnings.md +25 -0
- package/recipes/inbox-responder/recipe.yaml +82 -0
- package/recipes/linkedin-job-scraper/agent.md +144 -0
- package/recipes/linkedin-job-scraper/learnings.md +3 -0
- package/recipes/linkedin-job-scraper/recipe.yaml +71 -0
- package/recipes/linkedin-outreach/learnings.md +17 -1
- package/recipes/planner/agent.md +66 -1
- package/recipes/planner/learnings.md +49 -1
- package/recipes/sheets-data-entry/agent.md +166 -0
- package/recipes/sheets-data-entry/learnings.md +3 -0
- package/recipes/sheets-data-entry/recipe.yaml +78 -0
- package/recipes/social-ops/learnings.md +33 -13
- package/recipes/x-job-scraper/agent.md +156 -0
- package/recipes/x-job-scraper/learnings.md +3 -0
- package/recipes/x-job-scraper/recipe.yaml +72 -0
- package/dist/__tests__/correlation.test.js +0 -53
- package/dist/__tests__/correlation.test.js.map +0 -1
- /package/dist/{__tests__/correlation.test.d.ts → collections/__tests__/assertions.test.d.ts} +0 -0
|
@@ -0,0 +1,3065 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business intelligence tools — high-level compound tools that turn social
|
|
3
|
+
* media activity into leads, opportunities, and structured data.
|
|
4
|
+
*
|
|
5
|
+
* These chain existing extension primitives (openTab, scrollPage, extractData,
|
|
6
|
+
* etc.) from the bridge server. No new extension primitives needed.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import YAML from "yaml";
|
|
11
|
+
import { NEURON_DIR } from "./paths.js";
|
|
12
|
+
// ── Low-level helpers ───────────────────────────────────────
|
|
13
|
+
async function call(ctx, primitive, args, timeoutMs = 15000) {
|
|
14
|
+
return ctx.pending.call(ctx.ws, primitive, args, timeoutMs);
|
|
15
|
+
}
|
|
16
|
+
function sleep(ms) {
|
|
17
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
}
|
|
19
|
+
// ── Shared helpers ──────────────────────────────────────────
|
|
20
|
+
function ensureDir(dir) {
|
|
21
|
+
if (!fs.existsSync(dir)) {
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function loadYaml(file) {
|
|
26
|
+
if (!fs.existsSync(file))
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
return YAML.parse(fs.readFileSync(file, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function saveYaml(file, data) {
|
|
36
|
+
ensureDir(path.dirname(file));
|
|
37
|
+
fs.writeFileSync(file, YAML.stringify(data), "utf-8");
|
|
38
|
+
}
|
|
39
|
+
function detectPlatform(url) {
|
|
40
|
+
const u = url.toLowerCase();
|
|
41
|
+
if (u.includes("instagram.com"))
|
|
42
|
+
return "ig";
|
|
43
|
+
if (u.includes("x.com") || u.includes("twitter.com"))
|
|
44
|
+
return "x";
|
|
45
|
+
if (u.includes("tiktok.com"))
|
|
46
|
+
return "tk";
|
|
47
|
+
if (u.includes("linkedin.com"))
|
|
48
|
+
return "li";
|
|
49
|
+
if (u.includes("facebook.com") || u.includes("fb.com"))
|
|
50
|
+
return "fb";
|
|
51
|
+
if (u.includes("youtube.com") || u.includes("youtu.be"))
|
|
52
|
+
return "yt";
|
|
53
|
+
if (u.includes("threads.net"))
|
|
54
|
+
return "threads";
|
|
55
|
+
return "web";
|
|
56
|
+
}
|
|
57
|
+
function filterByKeywords(items, keywords) {
|
|
58
|
+
if (!keywords.length)
|
|
59
|
+
return items;
|
|
60
|
+
const lower = keywords.map((k) => k.toLowerCase());
|
|
61
|
+
return items.filter((item) => {
|
|
62
|
+
const text = JSON.stringify(item).toLowerCase();
|
|
63
|
+
return lower.some((kw) => text.includes(kw));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function researchSingleProfile(ctx, url, scrolls = 2) {
|
|
67
|
+
try {
|
|
68
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
69
|
+
const tabId = tab?.tabId;
|
|
70
|
+
if (!tabId)
|
|
71
|
+
return { url, platform: detectPlatform(url), data: null, error: "Failed to open tab" };
|
|
72
|
+
await sleep(2500);
|
|
73
|
+
for (let i = 0; i < scrolls; i++) {
|
|
74
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
75
|
+
await sleep(500);
|
|
76
|
+
}
|
|
77
|
+
const data = await call(ctx, "extractData", { tabId });
|
|
78
|
+
try {
|
|
79
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
80
|
+
}
|
|
81
|
+
catch { /* ok */ }
|
|
82
|
+
return { url, platform: detectPlatform(url), data, error: null };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return { url, platform: detectPlatform(url), data: null, error: err.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Per-platform selector map for social actions */
|
|
89
|
+
const PLATFORM_SELECTORS = {
|
|
90
|
+
ig: {
|
|
91
|
+
like: ["span[class*='like'] button", "button svg[aria-label*='Like']", "[aria-label='Like']"],
|
|
92
|
+
comment: ["textarea[aria-label*='comment']", "textarea[placeholder*='comment']"],
|
|
93
|
+
follow: ["button:has-text('Follow')", "[aria-label='Follow']"],
|
|
94
|
+
compose: ["[aria-label='New post']", "svg[aria-label='New post']"],
|
|
95
|
+
dm: ["textarea[placeholder*='Message']"],
|
|
96
|
+
},
|
|
97
|
+
x: {
|
|
98
|
+
like: ["button[data-testid='like']", "[aria-label*='Like']"],
|
|
99
|
+
reply: ["div[data-testid='tweetTextarea_0']", "[aria-label*='Post your reply']"],
|
|
100
|
+
follow: ["button[data-testid*='follow']", "[aria-label*='Follow']"],
|
|
101
|
+
compose: ["a[data-testid='SideNav_NewTweet_Button']", "[aria-label='Post']"],
|
|
102
|
+
dm: ["div[data-testid='dmComposerTextInput']"],
|
|
103
|
+
},
|
|
104
|
+
li: {
|
|
105
|
+
like: ["button[aria-label*='Like']", "button.react-button__trigger"],
|
|
106
|
+
comment: ["div.ql-editor[data-placeholder*='comment']", "[aria-label*='comment']"],
|
|
107
|
+
follow: ["button:has-text('Connect')", "button:has-text('Follow')"],
|
|
108
|
+
compose: ["button.share-box-feed-entry__trigger", "[aria-label='Start a post']"],
|
|
109
|
+
dm: ["div.msg-form__contenteditable", "[aria-label*='message']"],
|
|
110
|
+
},
|
|
111
|
+
fb: {
|
|
112
|
+
like: ["div[aria-label='Like']", "[data-testid='UFI2ReactionActionLink']"],
|
|
113
|
+
comment: ["div[contenteditable='true'][aria-label*='comment']"],
|
|
114
|
+
follow: ["[aria-label*='Follow']"],
|
|
115
|
+
compose: ["div[aria-label*='What\\'s on your mind']", "[data-testid='status-attachment-mentions-input']"],
|
|
116
|
+
dm: ["div[contenteditable='true'][aria-label*='message']"],
|
|
117
|
+
},
|
|
118
|
+
tk: {
|
|
119
|
+
like: ["span[data-e2e='like-icon']", "[aria-label*='Like']"],
|
|
120
|
+
comment: ["div[data-e2e='comment-input']", "[contenteditable='true']"],
|
|
121
|
+
follow: ["button[data-e2e='follow-button']"],
|
|
122
|
+
compose: [],
|
|
123
|
+
dm: [],
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
// ── Tool definitions ────────────────────────────────────────
|
|
127
|
+
export const BUSINESS_TOOLS = [
|
|
128
|
+
// ── Phase 1: Foundation ─────────────────────────────────
|
|
129
|
+
{
|
|
130
|
+
name: "neuron_profile_scanner",
|
|
131
|
+
description: "Scan a list of social media profile URLs — visit each, scroll to load content, extract structured " +
|
|
132
|
+
"profile data (name, bio, followers, posts). Supports session resume for large batches. Use for " +
|
|
133
|
+
"lead enrichment, competitor research, or building prospect lists from profile URLs.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
urls: {
|
|
138
|
+
type: "array",
|
|
139
|
+
items: { type: "string" },
|
|
140
|
+
description: "Profile URLs to scan (max 20 per call)",
|
|
141
|
+
},
|
|
142
|
+
scrolls: { type: "number", description: "Scroll-downs per profile to load content (default: 2)" },
|
|
143
|
+
session_id: { type: "string", description: "Session ID for resume support on large batches (optional)" },
|
|
144
|
+
delay_ms: { type: "number", description: "Delay between profiles in ms (default: 2000)" },
|
|
145
|
+
},
|
|
146
|
+
required: ["urls"],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "neuron_hashtag_scout",
|
|
151
|
+
description: "Scout a hashtag or topic feed — open the feed URL, scroll to collect posts, extract post data " +
|
|
152
|
+
"and authors. Optionally enrich by visiting each unique author's profile. Use for finding active " +
|
|
153
|
+
"creators in a niche, discovering trending content, or building outreach lists from hashtag feeds.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
url: { type: "string", description: "Hashtag or topic feed URL (e.g. https://instagram.com/explore/tags/startup/)" },
|
|
158
|
+
scrolls: { type: "number", description: "Number of scroll-downs to collect posts (default: 5)" },
|
|
159
|
+
enrich_profiles: { type: "boolean", description: "Visit each unique author's profile for full data (default: false)" },
|
|
160
|
+
max_profiles: { type: "number", description: "Max profiles to enrich (default: 10)" },
|
|
161
|
+
keywords: {
|
|
162
|
+
type: "array",
|
|
163
|
+
items: { type: "string" },
|
|
164
|
+
description: "Filter posts by keywords (optional)",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ["url"],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "neuron_site_xray",
|
|
172
|
+
description: "Full network analysis of any URL. Captures requests, performance metrics, security scan, API " +
|
|
173
|
+
"discovery, and waterfall timing in parallel. Post-processes into a categorized report: first-party " +
|
|
174
|
+
"vs third-party services, tracking pixels, CDN usage, API endpoints found, performance score, and " +
|
|
175
|
+
"security findings. Use for competitive analysis, tech stack discovery, or site auditing.",
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: {
|
|
179
|
+
url: { type: "string", description: "URL to analyze" },
|
|
180
|
+
scrolls: { type: "number", description: "Scroll-downs to trigger lazy-loaded resources (default: 3)" },
|
|
181
|
+
},
|
|
182
|
+
required: ["url"],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
// ── Phase 2: Collection tools ──────────────────────────
|
|
186
|
+
{
|
|
187
|
+
name: "neuron_collection_harvest",
|
|
188
|
+
description: "One-shot processing of a saved collection or feed URL. Deep-scrolls to load all items, extracts " +
|
|
189
|
+
"structured data, filters by keywords, and optionally enriches author profiles. Use for processing " +
|
|
190
|
+
"saved/bookmarked collections, liked-posts feeds, or any paginated content list.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
url: { type: "string", description: "Collection or feed URL to harvest" },
|
|
195
|
+
scrolls: { type: "number", description: "Number of deep scroll-downs (default: 10)" },
|
|
196
|
+
keywords: {
|
|
197
|
+
type: "array",
|
|
198
|
+
items: { type: "string" },
|
|
199
|
+
description: "Filter items by keywords (optional)",
|
|
200
|
+
},
|
|
201
|
+
enrich_profiles: { type: "boolean", description: "Visit each unique author's profile (default: false)" },
|
|
202
|
+
max_profiles: { type: "number", description: "Max profiles to enrich (default: 10)" },
|
|
203
|
+
selector: { type: "string", description: "CSS selector for items (optional — auto-detects)" },
|
|
204
|
+
},
|
|
205
|
+
required: ["url"],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "neuron_collection_watch",
|
|
210
|
+
description: "Watch a collection or feed URL for new items. Creates a persistent monitor that checks at " +
|
|
211
|
+
"intervals and alerts when new content matching your keywords appears. Use for watching competitor " +
|
|
212
|
+
"feeds, saved collections, or topic pages for new content.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
url: { type: "string", description: "Collection or feed URL to watch" },
|
|
217
|
+
name: { type: "string", description: "Human-readable name for this watch" },
|
|
218
|
+
keywords: {
|
|
219
|
+
type: "array",
|
|
220
|
+
items: { type: "string" },
|
|
221
|
+
description: "Only alert on items matching these keywords (optional)",
|
|
222
|
+
},
|
|
223
|
+
check_interval_minutes: { type: "number", description: "How often to check (default: 60)" },
|
|
224
|
+
selector: { type: "string", description: "CSS selector for items (optional)" },
|
|
225
|
+
},
|
|
226
|
+
required: ["url", "name"],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
// ── Phase 3: Prospecting ───────────────────────────────
|
|
230
|
+
{
|
|
231
|
+
name: "neuron_local_scout",
|
|
232
|
+
description: "Search location or explore feeds for businesses and creators. Navigates to a search/explore URL, " +
|
|
233
|
+
"optionally types a query, scrolls to collect results, and enriches unique author profiles. Filters " +
|
|
234
|
+
"by criteria like minimum followers, has-website, or keyword presence. Use for local business " +
|
|
235
|
+
"discovery or geographic lead generation.",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
url: { type: "string", description: "Search or explore URL (e.g. https://instagram.com/explore/locations/...)" },
|
|
240
|
+
query: { type: "string", description: "Search query to type (optional — some URLs pre-filter)" },
|
|
241
|
+
scrolls: { type: "number", description: "Scroll-downs to collect results (default: 5)" },
|
|
242
|
+
enrich_profiles: { type: "boolean", description: "Visit each author's profile for full data (default: true)" },
|
|
243
|
+
max_profiles: { type: "number", description: "Max profiles to enrich (default: 15)" },
|
|
244
|
+
min_followers: { type: "number", description: "Filter: minimum follower count (optional)" },
|
|
245
|
+
require_website: { type: "boolean", description: "Filter: only profiles with a website link (default: false)" },
|
|
246
|
+
keywords: {
|
|
247
|
+
type: "array",
|
|
248
|
+
items: { type: "string" },
|
|
249
|
+
description: "Filter by keywords in bio/posts (optional)",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
required: ["url"],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "neuron_demand_scanner",
|
|
257
|
+
description: "Scan feeds for posts matching trigger phrases — people asking for help, recommendations, or " +
|
|
258
|
+
"services. Uses search + keyword matching to find demand signals. Optionally creates a recurring " +
|
|
259
|
+
"monitor for scheduled checks. Use for finding inbound leads, customer pain points, or service requests.",
|
|
260
|
+
inputSchema: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties: {
|
|
263
|
+
url: { type: "string", description: "Feed or search URL to scan" },
|
|
264
|
+
triggers: {
|
|
265
|
+
type: "array",
|
|
266
|
+
items: { type: "string" },
|
|
267
|
+
description: "Trigger phrases to match (e.g. 'looking for', 'anyone know', 'recommend')",
|
|
268
|
+
},
|
|
269
|
+
scrolls: { type: "number", description: "Scroll-downs to collect posts (default: 5)" },
|
|
270
|
+
schedule: { type: "boolean", description: "Create a recurring monitor for this scan (default: false)" },
|
|
271
|
+
check_interval_minutes: { type: "number", description: "Monitor check interval if scheduled (default: 120)" },
|
|
272
|
+
enrich_profiles: { type: "boolean", description: "Enrich matched post authors' profiles (default: false)" },
|
|
273
|
+
},
|
|
274
|
+
required: ["url", "triggers"],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "neuron_opportunity_radar",
|
|
279
|
+
description: "Aggregator — reads recent alerts from all business monitors (Collection Watches, Demand Scanners, " +
|
|
280
|
+
"Page Watches). Merges, deduplicates, and sorts by recency. Returns a unified opportunity feed. " +
|
|
281
|
+
"Use as a daily digest of leads and opportunities across all your active monitors.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: {
|
|
285
|
+
limit: { type: "number", description: "Max opportunities to return (default: 50)" },
|
|
286
|
+
since_hours: { type: "number", description: "Only show alerts from the last N hours (default: 24)" },
|
|
287
|
+
keywords: {
|
|
288
|
+
type: "array",
|
|
289
|
+
items: { type: "string" },
|
|
290
|
+
description: "Filter opportunities by keywords (optional)",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
// ── Phase 4: Platform & Extraction ─────────────────────
|
|
296
|
+
{
|
|
297
|
+
name: "neuron_quick_action",
|
|
298
|
+
description: "Perform a social media action (like, comment, follow, DM, post) on the current tab or a URL. " +
|
|
299
|
+
"Uses per-platform selector registry. Auto-detects platform from tab URL. Use for quick engagement " +
|
|
300
|
+
"actions without remembering platform-specific selectors.",
|
|
301
|
+
inputSchema: {
|
|
302
|
+
type: "object",
|
|
303
|
+
properties: {
|
|
304
|
+
tabId: { type: "number", description: "Chrome tab ID (optional if url provided)" },
|
|
305
|
+
url: { type: "string", description: "URL to navigate to first (optional if tabId provided)" },
|
|
306
|
+
action: {
|
|
307
|
+
type: "string",
|
|
308
|
+
enum: ["like", "comment", "reply", "follow", "dm", "compose"],
|
|
309
|
+
description: "Social action to perform",
|
|
310
|
+
},
|
|
311
|
+
text: { type: "string", description: "Text content for comment, reply, dm, or compose actions" },
|
|
312
|
+
platform: { type: "string", description: "Override platform detection (ig, x, li, fb, tk)" },
|
|
313
|
+
},
|
|
314
|
+
required: ["action"],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "neuron_pattern_learner",
|
|
319
|
+
description: "Learn extraction patterns from examples. Give it 2-3 example text values from a page, and it " +
|
|
320
|
+
"identifies the common DOM structure, infers a CSS selector, and stores it for reuse. Then extracts " +
|
|
321
|
+
"all matching items. Use when auto-detection misses or you need a custom extraction pattern.",
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
tabId: { type: "number", description: "Chrome tab ID" },
|
|
326
|
+
examples: {
|
|
327
|
+
type: "array",
|
|
328
|
+
items: { type: "string" },
|
|
329
|
+
description: "2-3 example text values visible on the page",
|
|
330
|
+
},
|
|
331
|
+
name: { type: "string", description: "Name for this pattern (stored for reuse)" },
|
|
332
|
+
extract: { type: "boolean", description: "Extract all matches immediately (default: true)" },
|
|
333
|
+
},
|
|
334
|
+
required: ["tabId", "examples"],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
// ── Phase 5: Network & Monitoring ──────────────────────
|
|
338
|
+
{
|
|
339
|
+
name: "neuron_traffic_replay",
|
|
340
|
+
description: "Capture, save, and replay network traffic. Three modes: 'capture' saves current traffic as a " +
|
|
341
|
+
"HAR file; 'replay' loads a saved HAR and sets mock rules to simulate it; 'list' shows saved " +
|
|
342
|
+
"replays. Use for offline testing, reproducing bugs, or comparing API responses over time.",
|
|
343
|
+
inputSchema: {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: {
|
|
346
|
+
mode: {
|
|
347
|
+
type: "string",
|
|
348
|
+
enum: ["capture", "replay", "list"],
|
|
349
|
+
description: "Operation mode",
|
|
350
|
+
},
|
|
351
|
+
name: { type: "string", description: "Name for the capture/replay (required for capture and replay)" },
|
|
352
|
+
tabId: { type: "number", description: "Tab ID to capture traffic from (for capture mode)" },
|
|
353
|
+
platform: { type: "string", description: "Filter by platform (for capture mode)" },
|
|
354
|
+
navigate_url: { type: "string", description: "URL to navigate to after setting up mock rules (for replay mode)" },
|
|
355
|
+
},
|
|
356
|
+
required: ["mode"],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "neuron_api_blueprint",
|
|
361
|
+
description: "Enhanced API discovery — analyzes captured traffic, infers request/response schemas, detects auth " +
|
|
362
|
+
"patterns, and creates a Neuron API collection with all discovered endpoints. Use to reverse-engineer " +
|
|
363
|
+
"an API from observed traffic and create a ready-to-use collection for testing.",
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
collection_name: { type: "string", description: "Name for the API collection to create" },
|
|
368
|
+
url: { type: "string", description: "Optional: navigate to this URL first to capture fresh traffic" },
|
|
369
|
+
tabId: { type: "number", description: "Tab ID to analyze traffic from (optional)" },
|
|
370
|
+
include_auth: { type: "boolean", description: "Include auth headers in collection (default: true)" },
|
|
371
|
+
},
|
|
372
|
+
required: ["collection_name"],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "neuron_page_watch",
|
|
377
|
+
description: "Semantic page diffing monitor. Creates a persistent monitor that uses structured DOM snapshots " +
|
|
378
|
+
"instead of raw text comparison. Produces human-readable diff reports showing exactly what changed. " +
|
|
379
|
+
"Use to track changes on product pages, competitor sites, job boards, or any page with dynamic content.",
|
|
380
|
+
inputSchema: {
|
|
381
|
+
type: "object",
|
|
382
|
+
properties: {
|
|
383
|
+
url: { type: "string", description: "URL to watch for changes" },
|
|
384
|
+
name: { type: "string", description: "Human-readable name for this watch" },
|
|
385
|
+
selector: { type: "string", description: "CSS selector to scope the watch (optional — watches full page)" },
|
|
386
|
+
check_interval_minutes: { type: "number", description: "How often to check (default: 60)" },
|
|
387
|
+
keywords: {
|
|
388
|
+
type: "array",
|
|
389
|
+
items: { type: "string" },
|
|
390
|
+
description: "Only alert on changes containing these keywords (optional)",
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
required: ["url", "name"],
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
// ── Wave 2: Revenue & Lead Management ─────────────────────
|
|
397
|
+
{
|
|
398
|
+
name: "neuron_revenue_tracker",
|
|
399
|
+
description: "CRM-lite funnel tracker. Add leads with source attribution (which tool found them), move them " +
|
|
400
|
+
"through stages (discovered → contacted → replied → interested → negotiating → closed-won/lost), " +
|
|
401
|
+
"tag revenue, and see conversion rates per source. Persists to ~/.neuron/pipeline.yaml.",
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: "object",
|
|
404
|
+
properties: {
|
|
405
|
+
action: {
|
|
406
|
+
type: "string",
|
|
407
|
+
enum: ["add_lead", "update_stage", "tag_revenue", "list", "stats"],
|
|
408
|
+
description: "Pipeline action",
|
|
409
|
+
},
|
|
410
|
+
lead: {
|
|
411
|
+
type: "object",
|
|
412
|
+
description: "Lead data (for add_lead): { url, name, handle, platform, source, source_tool }",
|
|
413
|
+
},
|
|
414
|
+
lead_id: { type: "string", description: "Lead ID (for update_stage, tag_revenue)" },
|
|
415
|
+
stage: {
|
|
416
|
+
type: "string",
|
|
417
|
+
enum: ["discovered", "contacted", "replied", "interested", "negotiating", "closed_won", "closed_lost"],
|
|
418
|
+
description: "New stage (for update_stage)",
|
|
419
|
+
},
|
|
420
|
+
revenue: { type: "number", description: "Revenue amount (for tag_revenue)" },
|
|
421
|
+
filters: {
|
|
422
|
+
type: "object",
|
|
423
|
+
description: "Filters for list: { stage?, source?, platform?, since_days? }",
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
required: ["action"],
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: "neuron_lead_scorer",
|
|
431
|
+
description: "Score a list of leads based on configurable criteria: follower count, bio keywords (founder, CEO, " +
|
|
432
|
+
"business owner), engagement rate, posting recency, website presence, location match. Returns leads " +
|
|
433
|
+
"sorted by score with tier (hot/warm/cold) and recommended action per tier.",
|
|
434
|
+
inputSchema: {
|
|
435
|
+
type: "object",
|
|
436
|
+
properties: {
|
|
437
|
+
leads: {
|
|
438
|
+
type: "array",
|
|
439
|
+
items: { type: "object" },
|
|
440
|
+
description: "Lead objects with profile data (from profile_scanner, collection_harvest, etc.)",
|
|
441
|
+
},
|
|
442
|
+
rubric: {
|
|
443
|
+
type: "object",
|
|
444
|
+
description: "Scoring weights: { follower_min?, follower_max?, bio_keywords?: string[], " +
|
|
445
|
+
"require_website?: boolean, location_keywords?: string[], max_days_since_post?: number }",
|
|
446
|
+
},
|
|
447
|
+
hot_threshold: { type: "number", description: "Score above this = hot lead (default: 80)" },
|
|
448
|
+
warm_threshold: { type: "number", description: "Score above this = warm lead (default: 50)" },
|
|
449
|
+
},
|
|
450
|
+
required: ["leads"],
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "neuron_social_proof",
|
|
455
|
+
description: "Monitor and collect social proof — positive mentions, testimonials, and user-generated content " +
|
|
456
|
+
"about your brand. Scans posts, comments, and tags for positive sentiment. Captures the quote, " +
|
|
457
|
+
"poster profile, and post link into a testimonial bank at ~/.neuron/testimonials.yaml.",
|
|
458
|
+
inputSchema: {
|
|
459
|
+
type: "object",
|
|
460
|
+
properties: {
|
|
461
|
+
action: {
|
|
462
|
+
type: "string",
|
|
463
|
+
enum: ["scan", "list", "export"],
|
|
464
|
+
description: "Action: scan a URL for testimonials, list saved ones, or export",
|
|
465
|
+
},
|
|
466
|
+
url: { type: "string", description: "URL to scan for social proof (for scan action)" },
|
|
467
|
+
brand_keywords: {
|
|
468
|
+
type: "array",
|
|
469
|
+
items: { type: "string" },
|
|
470
|
+
description: "Brand/product names to look for",
|
|
471
|
+
},
|
|
472
|
+
positive_signals: {
|
|
473
|
+
type: "array",
|
|
474
|
+
items: { type: "string" },
|
|
475
|
+
description: "Positive phrases (default: 'changed my', 'love this', 'best', 'recommend', 'game changer')",
|
|
476
|
+
},
|
|
477
|
+
scrolls: { type: "number", description: "Scroll-downs to collect content (default: 5)" },
|
|
478
|
+
},
|
|
479
|
+
required: ["action"],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
// ── Wave 2: Outreach & Engagement ────────────────────────
|
|
483
|
+
{
|
|
484
|
+
name: "neuron_dm_outreach",
|
|
485
|
+
description: "Paced DM outreach from a lead list. Composes personalized messages from a template with merge " +
|
|
486
|
+
"fields ({name}, {handle}, {context}), sends one at a time with configurable delays to avoid " +
|
|
487
|
+
"platform limits, and tracks sent/replied status. Requires explicit start confirmation.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: {
|
|
491
|
+
action: {
|
|
492
|
+
type: "string",
|
|
493
|
+
enum: ["preview", "start", "status", "pause"],
|
|
494
|
+
description: "Outreach action",
|
|
495
|
+
},
|
|
496
|
+
campaign_id: { type: "string", description: "Campaign ID (for status/pause)" },
|
|
497
|
+
leads: {
|
|
498
|
+
type: "array",
|
|
499
|
+
items: { type: "object" },
|
|
500
|
+
description: "Lead list with profile URLs and merge data (for start)",
|
|
501
|
+
},
|
|
502
|
+
template: { type: "string", description: "Message template with {name}, {handle}, {context} merge fields" },
|
|
503
|
+
delay_minutes: { type: "number", description: "Minutes between messages (default: 5)" },
|
|
504
|
+
max_per_day: { type: "number", description: "Max messages per day (default: 20)" },
|
|
505
|
+
platform: { type: "string", description: "Target platform (ig, x, li)" },
|
|
506
|
+
},
|
|
507
|
+
required: ["action"],
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: "neuron_post_to_lead",
|
|
512
|
+
description: "Monitor a post's comments for a trigger keyword (e.g. 'GUIDE', 'INTERESTED'). Extract each " +
|
|
513
|
+
"matching commenter's profile, push as a lead. Optionally auto-queue a DM with a resource link. " +
|
|
514
|
+
"Use to turn engagement CTAs into a lead funnel automatically.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: {
|
|
518
|
+
post_url: { type: "string", description: "URL of the post to monitor" },
|
|
519
|
+
trigger: { type: "string", description: "Keyword to match in comments (e.g. 'GUIDE')" },
|
|
520
|
+
enrich_profiles: { type: "boolean", description: "Visit each commenter's profile (default: true)" },
|
|
521
|
+
dm_template: { type: "string", description: "Optional DM template to queue for matched commenters" },
|
|
522
|
+
scrolls: { type: "number", description: "Scroll-downs on comments section (default: 5)" },
|
|
523
|
+
schedule: { type: "boolean", description: "Create a recurring monitor (default: false)" },
|
|
524
|
+
check_interval_minutes: { type: "number", description: "Monitor interval if scheduled (default: 30)" },
|
|
525
|
+
},
|
|
526
|
+
required: ["post_url", "trigger"],
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "neuron_comment_miner",
|
|
531
|
+
description: "Extract every commenter from a post (yours or anyone's) with their profile data. Filter by " +
|
|
532
|
+
"keyword in their comment text. Returns a structured lead list. Use to turn a viral post's " +
|
|
533
|
+
"comment section into qualified leads.",
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
post_url: { type: "string", description: "URL of the post to mine comments from" },
|
|
538
|
+
keywords: {
|
|
539
|
+
type: "array",
|
|
540
|
+
items: { type: "string" },
|
|
541
|
+
description: "Filter commenters by keywords in their comment (optional)",
|
|
542
|
+
},
|
|
543
|
+
enrich_profiles: { type: "boolean", description: "Visit each commenter's profile (default: true)" },
|
|
544
|
+
max_profiles: { type: "number", description: "Max profiles to enrich (default: 20)" },
|
|
545
|
+
scrolls: { type: "number", description: "Scroll-downs on comments (default: 5)" },
|
|
546
|
+
},
|
|
547
|
+
required: ["post_url"],
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
// ── Wave 2: Competitive Intelligence ─────────────────────
|
|
551
|
+
{
|
|
552
|
+
name: "neuron_competitor_mapper",
|
|
553
|
+
description: "Map a competitor's engaged audience. Visit their profile, scan recent posts' comments, rank " +
|
|
554
|
+
"commenters by frequency, and enrich top commenters' profiles. Categorizes each as potential " +
|
|
555
|
+
"customer, fellow business, influencer, or casual follower.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
profile_url: { type: "string", description: "Competitor's profile URL" },
|
|
560
|
+
posts_to_scan: { type: "number", description: "Number of recent posts to scan comments from (default: 5)" },
|
|
561
|
+
max_commenters: { type: "number", description: "Max commenters to enrich (default: 20)" },
|
|
562
|
+
scrolls_per_post: { type: "number", description: "Scroll-downs per post's comments (default: 3)" },
|
|
563
|
+
your_handle: { type: "string", description: "Your handle — to detect overlap (people who engage with both)" },
|
|
564
|
+
},
|
|
565
|
+
required: ["profile_url"],
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: "neuron_influencer_vetter",
|
|
570
|
+
description: "Vet an influencer profile for authenticity. Calculates engagement rate, analyzes comment quality " +
|
|
571
|
+
"(generic vs substantive), checks posting consistency, and flags red flags like purchased followers. " +
|
|
572
|
+
"Returns a trust score (0-100) with breakdown.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
profile_url: { type: "string", description: "Influencer's profile URL" },
|
|
577
|
+
posts_to_analyze: { type: "number", description: "Number of recent posts to analyze (default: 10)" },
|
|
578
|
+
scrolls: { type: "number", description: "Scroll-downs on profile (default: 3)" },
|
|
579
|
+
},
|
|
580
|
+
required: ["profile_url"],
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "neuron_engagement_analyzer",
|
|
585
|
+
description: "Analyze your own posting performance. Scans your last N posts, calculates best posting times, " +
|
|
586
|
+
"top content types, effective hashtags, engagement trends, and returns specific recommendations. " +
|
|
587
|
+
"Like Sprout Social analytics but free.",
|
|
588
|
+
inputSchema: {
|
|
589
|
+
type: "object",
|
|
590
|
+
properties: {
|
|
591
|
+
profile_url: { type: "string", description: "Your profile URL" },
|
|
592
|
+
posts_to_analyze: { type: "number", description: "Number of recent posts to analyze (default: 20)" },
|
|
593
|
+
scrolls: { type: "number", description: "Scroll-downs on profile (default: 5)" },
|
|
594
|
+
},
|
|
595
|
+
required: ["profile_url"],
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
// ── Wave 2: Brand & Content ──────────────────────────────
|
|
599
|
+
{
|
|
600
|
+
name: "neuron_mention_tracker",
|
|
601
|
+
description: "Track who mentions your brand, handle, or keywords across platforms. Scan a search/hashtag URL, " +
|
|
602
|
+
"extract posts mentioning your brand, capture poster profile and sentiment. Optionally schedule " +
|
|
603
|
+
"recurring checks. Persists mentions to ~/.neuron/mentions.yaml.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
type: "object",
|
|
606
|
+
properties: {
|
|
607
|
+
url: { type: "string", description: "Search or feed URL to scan for mentions" },
|
|
608
|
+
brand_terms: {
|
|
609
|
+
type: "array",
|
|
610
|
+
items: { type: "string" },
|
|
611
|
+
description: "Brand names, handles, or keywords to track",
|
|
612
|
+
},
|
|
613
|
+
scrolls: { type: "number", description: "Scroll-downs to collect posts (default: 5)" },
|
|
614
|
+
enrich_profiles: { type: "boolean", description: "Enrich mention authors' profiles (default: false)" },
|
|
615
|
+
schedule: { type: "boolean", description: "Create recurring monitor (default: false)" },
|
|
616
|
+
check_interval_minutes: { type: "number", description: "Monitor interval if scheduled (default: 60)" },
|
|
617
|
+
},
|
|
618
|
+
required: ["url", "brand_terms"],
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "neuron_post_analyzer",
|
|
623
|
+
description: "Analyze a social post or batch of posts. Extract text, media type, engagement metrics, hashtags, " +
|
|
624
|
+
"mentions, posting time, and content classification (educational, promotional, storytelling). " +
|
|
625
|
+
"Batch mode: analyze a profile's feed and return a spreadsheet-ready breakdown.",
|
|
626
|
+
inputSchema: {
|
|
627
|
+
type: "object",
|
|
628
|
+
properties: {
|
|
629
|
+
url: { type: "string", description: "Post URL or profile URL (for batch mode)" },
|
|
630
|
+
batch: { type: "boolean", description: "Analyze multiple posts from a profile feed (default: false)" },
|
|
631
|
+
posts_count: { type: "number", description: "Number of posts for batch mode (default: 20)" },
|
|
632
|
+
scrolls: { type: "number", description: "Scroll-downs to load posts (default: 3)" },
|
|
633
|
+
},
|
|
634
|
+
required: ["url"],
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
name: "neuron_content_pipeline",
|
|
639
|
+
description: "Extract content from a source URL (article, thread, post) and restructure it for a target " +
|
|
640
|
+
"platform format. Returns platform-optimized content with hooks and formatting. Use for content " +
|
|
641
|
+
"repurposing across platforms.",
|
|
642
|
+
inputSchema: {
|
|
643
|
+
type: "object",
|
|
644
|
+
properties: {
|
|
645
|
+
source_url: { type: "string", description: "Source content URL (article, post, thread)" },
|
|
646
|
+
target_platform: {
|
|
647
|
+
type: "string",
|
|
648
|
+
enum: ["linkedin", "x", "instagram", "tiktok", "newsletter", "blog"],
|
|
649
|
+
description: "Target platform format",
|
|
650
|
+
},
|
|
651
|
+
scrolls: { type: "number", description: "Scroll-downs on source page (default: 5)" },
|
|
652
|
+
tone: { type: "string", description: "Tone override: professional, casual, educational (optional)" },
|
|
653
|
+
},
|
|
654
|
+
required: ["source_url", "target_platform"],
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
// ── Wave 2: Data Collection ──────────────────────────────
|
|
658
|
+
{
|
|
659
|
+
name: "neuron_group_link_scanner",
|
|
660
|
+
description: "Scan posts, comments, bios for WhatsApp and Telegram group invite links (wa.me, chat.whatsapp.com, " +
|
|
661
|
+
"t.me). Extract the link, context, and poster profile. Push to ~/.neuron/group-links.yaml. " +
|
|
662
|
+
"Optionally validate if links are still active.",
|
|
663
|
+
inputSchema: {
|
|
664
|
+
type: "object",
|
|
665
|
+
properties: {
|
|
666
|
+
url: { type: "string", description: "URL to scan (feed, post, profile)" },
|
|
667
|
+
scrolls: { type: "number", description: "Scroll-downs to collect content (default: 5)" },
|
|
668
|
+
validate_links: { type: "boolean", description: "Check if group links are still active (default: false)" },
|
|
669
|
+
enrich_profiles: { type: "boolean", description: "Enrich link poster profiles (default: false)" },
|
|
670
|
+
},
|
|
671
|
+
required: ["url"],
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: "neuron_bio_harvester",
|
|
676
|
+
description: "Scan a list of profile URLs and extract every link from their bios — websites, Linktree, WhatsApp, " +
|
|
677
|
+
"Calendly, email, phone. Follows link-in-bio pages (Linktree, Beacons) to extract destination URLs. " +
|
|
678
|
+
"Categorizes each link type.",
|
|
679
|
+
inputSchema: {
|
|
680
|
+
type: "object",
|
|
681
|
+
properties: {
|
|
682
|
+
urls: {
|
|
683
|
+
type: "array",
|
|
684
|
+
items: { type: "string" },
|
|
685
|
+
description: "Profile URLs to harvest bio links from (max 20)",
|
|
686
|
+
},
|
|
687
|
+
follow_linktree: { type: "boolean", description: "Follow and expand link-in-bio pages (default: true)" },
|
|
688
|
+
delay_ms: { type: "number", description: "Delay between profiles in ms (default: 2000)" },
|
|
689
|
+
},
|
|
690
|
+
required: ["urls"],
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "neuron_cross_platform_finder",
|
|
695
|
+
description: "Find a person across platforms. Given a handle on one platform, search for matching accounts on " +
|
|
696
|
+
"others by handle, name, and bio similarity. Returns a unified profile card with confidence scores.",
|
|
697
|
+
inputSchema: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
handle: { type: "string", description: "Handle to search for (e.g. 'rasheedalabi')" },
|
|
701
|
+
source_platform: { type: "string", description: "Platform the handle is from (ig, x, li, tk, fb)" },
|
|
702
|
+
search_platforms: {
|
|
703
|
+
type: "array",
|
|
704
|
+
items: { type: "string" },
|
|
705
|
+
description: "Platforms to search on (default: all except source)",
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
required: ["handle", "source_platform"],
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
name: "neuron_page_weight",
|
|
713
|
+
description: "Break down total page weight by category (JS, CSS, images, fonts, video, third-party). Flag " +
|
|
714
|
+
"oversized assets with specific recommendations. Compare against performance budgets. Returns a " +
|
|
715
|
+
"prioritized action list for optimization.",
|
|
716
|
+
inputSchema: {
|
|
717
|
+
type: "object",
|
|
718
|
+
properties: {
|
|
719
|
+
url: { type: "string", description: "URL to profile" },
|
|
720
|
+
budget: {
|
|
721
|
+
type: "object",
|
|
722
|
+
description: "Performance budget: { max_js_kb?, max_css_kb?, max_images_kb?, max_total_kb?, max_single_asset_kb? }",
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
required: ["url"],
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
// ── Handler dispatcher ──────────────────────────────────────
|
|
730
|
+
export async function handleBusinessTool(name, args, ctx) {
|
|
731
|
+
switch (name) {
|
|
732
|
+
case "neuron_profile_scanner":
|
|
733
|
+
return profileScanner(args, ctx);
|
|
734
|
+
case "neuron_hashtag_scout":
|
|
735
|
+
return hashtagScout(args, ctx);
|
|
736
|
+
case "neuron_site_xray":
|
|
737
|
+
return siteXray(args, ctx);
|
|
738
|
+
case "neuron_collection_harvest":
|
|
739
|
+
return collectionHarvest(args, ctx);
|
|
740
|
+
case "neuron_collection_watch":
|
|
741
|
+
return collectionWatch(args);
|
|
742
|
+
case "neuron_local_scout":
|
|
743
|
+
return localScout(args, ctx);
|
|
744
|
+
case "neuron_demand_scanner":
|
|
745
|
+
return demandScanner(args, ctx);
|
|
746
|
+
case "neuron_opportunity_radar":
|
|
747
|
+
return opportunityRadar(args);
|
|
748
|
+
case "neuron_quick_action":
|
|
749
|
+
return quickAction(args, ctx);
|
|
750
|
+
case "neuron_pattern_learner":
|
|
751
|
+
return patternLearner(args, ctx);
|
|
752
|
+
case "neuron_traffic_replay":
|
|
753
|
+
return trafficReplay(args, ctx);
|
|
754
|
+
case "neuron_api_blueprint":
|
|
755
|
+
return apiBlueprint(args, ctx);
|
|
756
|
+
case "neuron_page_watch":
|
|
757
|
+
return pageWatch(args);
|
|
758
|
+
// Wave 2
|
|
759
|
+
case "neuron_revenue_tracker":
|
|
760
|
+
return revenueTracker(args);
|
|
761
|
+
case "neuron_lead_scorer":
|
|
762
|
+
return leadScorer(args);
|
|
763
|
+
case "neuron_social_proof":
|
|
764
|
+
return socialProof(args, ctx);
|
|
765
|
+
case "neuron_dm_outreach":
|
|
766
|
+
return dmOutreach(args, ctx);
|
|
767
|
+
case "neuron_post_to_lead":
|
|
768
|
+
return postToLead(args, ctx);
|
|
769
|
+
case "neuron_comment_miner":
|
|
770
|
+
return commentMiner(args, ctx);
|
|
771
|
+
case "neuron_competitor_mapper":
|
|
772
|
+
return competitorMapper(args, ctx);
|
|
773
|
+
case "neuron_influencer_vetter":
|
|
774
|
+
return influencerVetter(args, ctx);
|
|
775
|
+
case "neuron_engagement_analyzer":
|
|
776
|
+
return engagementAnalyzer(args, ctx);
|
|
777
|
+
case "neuron_mention_tracker":
|
|
778
|
+
return mentionTracker(args, ctx);
|
|
779
|
+
case "neuron_post_analyzer":
|
|
780
|
+
return postAnalyzer(args, ctx);
|
|
781
|
+
case "neuron_content_pipeline":
|
|
782
|
+
return contentPipeline(args, ctx);
|
|
783
|
+
case "neuron_group_link_scanner":
|
|
784
|
+
return groupLinkScanner(args, ctx);
|
|
785
|
+
case "neuron_bio_harvester":
|
|
786
|
+
return bioHarvester(args, ctx);
|
|
787
|
+
case "neuron_cross_platform_finder":
|
|
788
|
+
return crossPlatformFinder(args, ctx);
|
|
789
|
+
case "neuron_page_weight":
|
|
790
|
+
return pageWeight(args, ctx);
|
|
791
|
+
default:
|
|
792
|
+
throw new Error(`Unknown business tool: ${name}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// ── Implementations ─────────────────────────────────────────
|
|
796
|
+
// ── Phase 1 ─────────────────────────────────────────────────
|
|
797
|
+
async function profileScanner(args, ctx) {
|
|
798
|
+
const urls = args.urls.slice(0, 20);
|
|
799
|
+
const scrolls = args.scrolls ?? 2;
|
|
800
|
+
const sessionId = args.session_id;
|
|
801
|
+
const delayMs = args.delay_ms ?? 2000;
|
|
802
|
+
// Session resume support
|
|
803
|
+
let startIndex = 0;
|
|
804
|
+
const completed = [];
|
|
805
|
+
if (sessionId) {
|
|
806
|
+
const sessionFile = path.join(NEURON_DIR, "sessions", `${sessionId}.yaml`);
|
|
807
|
+
const existing = loadYaml(sessionFile);
|
|
808
|
+
if (existing?.cursor != null) {
|
|
809
|
+
startIndex = existing.cursor;
|
|
810
|
+
if (existing.completed)
|
|
811
|
+
completed.push(...existing.completed);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const results = [];
|
|
815
|
+
for (let i = startIndex; i < urls.length; i++) {
|
|
816
|
+
const result = await researchSingleProfile(ctx, urls[i], scrolls);
|
|
817
|
+
results.push(result);
|
|
818
|
+
completed.push(result);
|
|
819
|
+
// Save progress if session tracking
|
|
820
|
+
if (sessionId) {
|
|
821
|
+
const sessionFile = path.join(NEURON_DIR, "sessions", `${sessionId}.yaml`);
|
|
822
|
+
saveYaml(sessionFile, {
|
|
823
|
+
session_id: sessionId,
|
|
824
|
+
cursor: i + 1,
|
|
825
|
+
total: urls.length,
|
|
826
|
+
completed,
|
|
827
|
+
updated_at: new Date().toISOString(),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (i < urls.length - 1 && delayMs > 0)
|
|
831
|
+
await sleep(delayMs);
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
total: urls.length,
|
|
835
|
+
scanned: results.length,
|
|
836
|
+
successful: results.filter((r) => r.data && !r.error).length,
|
|
837
|
+
failed: results.filter((r) => r.error).length,
|
|
838
|
+
session_id: sessionId ?? null,
|
|
839
|
+
profiles: results,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
async function hashtagScout(args, ctx) {
|
|
843
|
+
const url = args.url;
|
|
844
|
+
const scrollCount = args.scrolls ?? 5;
|
|
845
|
+
const enrichProfiles = args.enrich_profiles ?? false;
|
|
846
|
+
const maxProfiles = args.max_profiles ?? 10;
|
|
847
|
+
const keywords = args.keywords ?? [];
|
|
848
|
+
// Open the feed
|
|
849
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
850
|
+
const tabId = tab?.tabId;
|
|
851
|
+
if (!tabId)
|
|
852
|
+
throw new Error("Failed to open tab");
|
|
853
|
+
await sleep(3000);
|
|
854
|
+
// Scroll to collect posts
|
|
855
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
856
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
|
|
857
|
+
await sleep(700);
|
|
858
|
+
}
|
|
859
|
+
// Extract posts
|
|
860
|
+
const rawData = (await call(ctx, "extractData", { tabId }));
|
|
861
|
+
let items = Array.isArray(rawData)
|
|
862
|
+
? rawData
|
|
863
|
+
: rawData?.items ?? [rawData];
|
|
864
|
+
// Filter by keywords
|
|
865
|
+
if (keywords.length > 0) {
|
|
866
|
+
items = filterByKeywords(items, keywords);
|
|
867
|
+
}
|
|
868
|
+
// Extract unique author URLs from items
|
|
869
|
+
const authorUrls = new Set();
|
|
870
|
+
for (const item of items) {
|
|
871
|
+
const obj = item;
|
|
872
|
+
// Look for profile links in the extracted data
|
|
873
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? obj.author_url ?? "");
|
|
874
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
875
|
+
for (const link of linkList) {
|
|
876
|
+
if (typeof link === "string" && link.includes("/") && !link.includes("/p/") && !link.includes("/reel/")) {
|
|
877
|
+
authorUrls.add(link);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// Enrich profiles if requested
|
|
882
|
+
let enriched = [];
|
|
883
|
+
if (enrichProfiles && authorUrls.size > 0) {
|
|
884
|
+
const toEnrich = [...authorUrls].slice(0, maxProfiles);
|
|
885
|
+
for (const profileUrl of toEnrich) {
|
|
886
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
887
|
+
await sleep(1500);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// Close the feed tab
|
|
891
|
+
try {
|
|
892
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
893
|
+
}
|
|
894
|
+
catch { /* ok */ }
|
|
895
|
+
return {
|
|
896
|
+
url,
|
|
897
|
+
platform: detectPlatform(url),
|
|
898
|
+
total_posts: items.length,
|
|
899
|
+
keywords_applied: keywords,
|
|
900
|
+
posts: items.slice(0, 50),
|
|
901
|
+
unique_authors: authorUrls.size,
|
|
902
|
+
enriched_profiles: enriched.length > 0 ? enriched : undefined,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
async function siteXray(args, ctx) {
|
|
906
|
+
const url = args.url;
|
|
907
|
+
const scrollCount = args.scrolls ?? 3;
|
|
908
|
+
// Open the page
|
|
909
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
910
|
+
const tabId = tab?.tabId;
|
|
911
|
+
if (!tabId)
|
|
912
|
+
throw new Error("Failed to open tab");
|
|
913
|
+
await sleep(3000);
|
|
914
|
+
// Scroll to trigger lazy resources
|
|
915
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
916
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
917
|
+
await sleep(500);
|
|
918
|
+
}
|
|
919
|
+
// Run all analyses in parallel
|
|
920
|
+
const [requests, waterfallData, perf, security, apis] = await Promise.allSettled([
|
|
921
|
+
call(ctx, "getRecentRequests", { tabId, limit: 200 }),
|
|
922
|
+
call(ctx, "waterfall", { tabId }),
|
|
923
|
+
call(ctx, "perfSnapshot", { tabId }),
|
|
924
|
+
call(ctx, "securityScan", { tabId }),
|
|
925
|
+
call(ctx, "discoverApis", {}),
|
|
926
|
+
]);
|
|
927
|
+
// Categorize requests
|
|
928
|
+
const requestList = requests.status === "fulfilled"
|
|
929
|
+
? (requests.value?.requests ?? [])
|
|
930
|
+
: [];
|
|
931
|
+
const hostname = new URL(url).hostname;
|
|
932
|
+
const categories = {
|
|
933
|
+
first_party: [],
|
|
934
|
+
third_party: [],
|
|
935
|
+
tracking: [],
|
|
936
|
+
cdn: [],
|
|
937
|
+
api: [],
|
|
938
|
+
};
|
|
939
|
+
const trackingDomains = [
|
|
940
|
+
"google-analytics", "googletagmanager", "facebook.com/tr", "pixel",
|
|
941
|
+
"analytics", "hotjar", "mixpanel", "segment", "amplitude",
|
|
942
|
+
"doubleclick", "adsense", "adservice", "tracking",
|
|
943
|
+
];
|
|
944
|
+
const cdnDomains = [
|
|
945
|
+
"cloudfront", "cdn", "akamai", "fastly", "cloudflare", "jsdelivr",
|
|
946
|
+
"unpkg", "cdnjs",
|
|
947
|
+
];
|
|
948
|
+
for (const req of requestList) {
|
|
949
|
+
const r = req;
|
|
950
|
+
const reqUrl = String(r.url ?? "");
|
|
951
|
+
try {
|
|
952
|
+
const reqHost = new URL(reqUrl).hostname;
|
|
953
|
+
const lower = reqUrl.toLowerCase();
|
|
954
|
+
if (trackingDomains.some((d) => lower.includes(d))) {
|
|
955
|
+
categories.tracking.push({ url: reqUrl, host: reqHost });
|
|
956
|
+
}
|
|
957
|
+
else if (cdnDomains.some((d) => lower.includes(d))) {
|
|
958
|
+
categories.cdn.push({ url: reqUrl, host: reqHost });
|
|
959
|
+
}
|
|
960
|
+
else if (lower.includes("/api/") || lower.includes("/graphql") || lower.includes("/v1/") || lower.includes("/v2/")) {
|
|
961
|
+
categories.api.push({ url: reqUrl, host: reqHost });
|
|
962
|
+
}
|
|
963
|
+
else if (reqHost === hostname || reqHost.endsWith(`.${hostname}`)) {
|
|
964
|
+
categories.first_party.push({ url: reqUrl, host: reqHost });
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
categories.third_party.push({ url: reqUrl, host: reqHost });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// skip malformed URLs
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Close tab
|
|
975
|
+
try {
|
|
976
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
977
|
+
}
|
|
978
|
+
catch { /* ok */ }
|
|
979
|
+
return {
|
|
980
|
+
url,
|
|
981
|
+
total_requests: requestList.length,
|
|
982
|
+
categories: {
|
|
983
|
+
first_party: categories.first_party.length,
|
|
984
|
+
third_party: categories.third_party.length,
|
|
985
|
+
tracking: categories.tracking.length,
|
|
986
|
+
cdn: categories.cdn.length,
|
|
987
|
+
api: categories.api.length,
|
|
988
|
+
},
|
|
989
|
+
tracking_services: [...new Set(categories.tracking.map((t) => t.host))],
|
|
990
|
+
third_party_services: [...new Set(categories.third_party.map((t) => t.host))].slice(0, 20),
|
|
991
|
+
api_endpoints: categories.api.slice(0, 20),
|
|
992
|
+
waterfall: waterfallData.status === "fulfilled" ? waterfallData.value : { error: "unavailable" },
|
|
993
|
+
performance: perf.status === "fulfilled" ? perf.value : { error: "unavailable" },
|
|
994
|
+
security: security.status === "fulfilled" ? security.value : { error: "unavailable" },
|
|
995
|
+
discovered_apis: apis.status === "fulfilled" ? apis.value : { error: "unavailable" },
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
// ── Phase 2 ─────────────────────────────────────────────────
|
|
999
|
+
async function collectionHarvest(args, ctx) {
|
|
1000
|
+
const url = args.url;
|
|
1001
|
+
const scrollCount = args.scrolls ?? 10;
|
|
1002
|
+
const keywords = args.keywords ?? [];
|
|
1003
|
+
const enrichProfiles = args.enrich_profiles ?? false;
|
|
1004
|
+
const maxProfiles = args.max_profiles ?? 10;
|
|
1005
|
+
const selector = args.selector;
|
|
1006
|
+
// Open the collection
|
|
1007
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1008
|
+
const tabId = tab?.tabId;
|
|
1009
|
+
if (!tabId)
|
|
1010
|
+
throw new Error("Failed to open tab");
|
|
1011
|
+
await sleep(3000);
|
|
1012
|
+
// Deep scroll to load all items
|
|
1013
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
1014
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 1000, smooth: true });
|
|
1015
|
+
await sleep(800);
|
|
1016
|
+
}
|
|
1017
|
+
// Extract all items
|
|
1018
|
+
const extractArgs = { tabId };
|
|
1019
|
+
if (selector)
|
|
1020
|
+
extractArgs.selector = selector;
|
|
1021
|
+
const rawData = (await call(ctx, "extractData", extractArgs));
|
|
1022
|
+
let items = Array.isArray(rawData)
|
|
1023
|
+
? rawData
|
|
1024
|
+
: rawData?.items ?? [rawData];
|
|
1025
|
+
// Filter by keywords
|
|
1026
|
+
const preFilterCount = items.length;
|
|
1027
|
+
if (keywords.length > 0) {
|
|
1028
|
+
items = filterByKeywords(items, keywords);
|
|
1029
|
+
}
|
|
1030
|
+
// Extract unique author URLs
|
|
1031
|
+
const authorUrls = new Set();
|
|
1032
|
+
for (const item of items) {
|
|
1033
|
+
const obj = item;
|
|
1034
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? obj.author_url ?? "");
|
|
1035
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
1036
|
+
for (const link of linkList) {
|
|
1037
|
+
if (typeof link === "string" && link.startsWith("http")) {
|
|
1038
|
+
authorUrls.add(link);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
// Enrich profiles
|
|
1043
|
+
let enriched = [];
|
|
1044
|
+
if (enrichProfiles && authorUrls.size > 0) {
|
|
1045
|
+
const toEnrich = [...authorUrls].slice(0, maxProfiles);
|
|
1046
|
+
for (const profileUrl of toEnrich) {
|
|
1047
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
1048
|
+
await sleep(1500);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Close tab
|
|
1052
|
+
try {
|
|
1053
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
1054
|
+
}
|
|
1055
|
+
catch { /* ok */ }
|
|
1056
|
+
return {
|
|
1057
|
+
url,
|
|
1058
|
+
total_extracted: preFilterCount,
|
|
1059
|
+
after_filter: items.length,
|
|
1060
|
+
keywords_applied: keywords,
|
|
1061
|
+
items: items.slice(0, 100),
|
|
1062
|
+
unique_authors: authorUrls.size,
|
|
1063
|
+
enriched_profiles: enriched.length > 0 ? enriched : undefined,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
async function collectionWatch(args) {
|
|
1067
|
+
const url = args.url;
|
|
1068
|
+
const name = args.name;
|
|
1069
|
+
const keywords = args.keywords ?? [];
|
|
1070
|
+
const intervalMin = args.check_interval_minutes ?? 60;
|
|
1071
|
+
const selector = args.selector;
|
|
1072
|
+
// Load existing monitors
|
|
1073
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
1074
|
+
const store = loadYaml(monitorsFile) ?? { monitors: [] };
|
|
1075
|
+
const monitorId = `cw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1076
|
+
const monitor = {
|
|
1077
|
+
id: monitorId,
|
|
1078
|
+
url,
|
|
1079
|
+
name,
|
|
1080
|
+
check_interval_minutes: intervalMin,
|
|
1081
|
+
condition: {
|
|
1082
|
+
type: "new_items",
|
|
1083
|
+
selector: selector ?? undefined,
|
|
1084
|
+
metadata: { keywords, source: "collection_watch" },
|
|
1085
|
+
},
|
|
1086
|
+
enabled: true,
|
|
1087
|
+
created_at: new Date().toISOString(),
|
|
1088
|
+
alert_count: 0,
|
|
1089
|
+
};
|
|
1090
|
+
store.monitors.push(monitor);
|
|
1091
|
+
saveYaml(monitorsFile, store);
|
|
1092
|
+
return {
|
|
1093
|
+
status: "created",
|
|
1094
|
+
monitor_id: monitorId,
|
|
1095
|
+
name,
|
|
1096
|
+
url,
|
|
1097
|
+
check_interval_minutes: intervalMin,
|
|
1098
|
+
keywords,
|
|
1099
|
+
message: `Collection watch "${name}" created. It will check every ${intervalMin} minutes for new items.`,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
// ── Phase 3 ─────────────────────────────────────────────────
|
|
1103
|
+
async function localScout(args, ctx) {
|
|
1104
|
+
const url = args.url;
|
|
1105
|
+
const query = args.query;
|
|
1106
|
+
const scrollCount = args.scrolls ?? 5;
|
|
1107
|
+
const enrichProfiles = args.enrich_profiles ?? true;
|
|
1108
|
+
const maxProfiles = args.max_profiles ?? 15;
|
|
1109
|
+
const minFollowers = args.min_followers;
|
|
1110
|
+
const requireWebsite = args.require_website ?? false;
|
|
1111
|
+
const keywords = args.keywords ?? [];
|
|
1112
|
+
// Open the page
|
|
1113
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1114
|
+
const tabId = tab?.tabId;
|
|
1115
|
+
if (!tabId)
|
|
1116
|
+
throw new Error("Failed to open tab");
|
|
1117
|
+
await sleep(3000);
|
|
1118
|
+
// Type query if provided
|
|
1119
|
+
if (query) {
|
|
1120
|
+
const searchSelectors = [
|
|
1121
|
+
"input[type='search']", "input[name='q']", "input[name='keywords']",
|
|
1122
|
+
"input[placeholder*='earch']", "input[aria-label*='earch']",
|
|
1123
|
+
];
|
|
1124
|
+
try {
|
|
1125
|
+
await call(ctx, "typeText", { tabId, selectors: searchSelectors, value: query });
|
|
1126
|
+
await sleep(500);
|
|
1127
|
+
await call(ctx, "pressKey", { tabId, key: "Enter" });
|
|
1128
|
+
await sleep(2500);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// search box might not exist on explore pages
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Scroll to collect results
|
|
1135
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
1136
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
|
|
1137
|
+
await sleep(700);
|
|
1138
|
+
}
|
|
1139
|
+
// Extract results
|
|
1140
|
+
const rawData = (await call(ctx, "extractData", { tabId }));
|
|
1141
|
+
let items = Array.isArray(rawData)
|
|
1142
|
+
? rawData
|
|
1143
|
+
: rawData?.items ?? [rawData];
|
|
1144
|
+
// Filter by keywords
|
|
1145
|
+
if (keywords.length > 0) {
|
|
1146
|
+
items = filterByKeywords(items, keywords);
|
|
1147
|
+
}
|
|
1148
|
+
// Extract unique profile URLs
|
|
1149
|
+
const authorUrls = new Set();
|
|
1150
|
+
for (const item of items) {
|
|
1151
|
+
const obj = item;
|
|
1152
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
1153
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
1154
|
+
for (const link of linkList) {
|
|
1155
|
+
if (typeof link === "string" && link.startsWith("http")) {
|
|
1156
|
+
authorUrls.add(link);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// Enrich profiles
|
|
1161
|
+
let enriched = [];
|
|
1162
|
+
if (enrichProfiles && authorUrls.size > 0) {
|
|
1163
|
+
const toEnrich = [...authorUrls].slice(0, maxProfiles);
|
|
1164
|
+
for (const profileUrl of toEnrich) {
|
|
1165
|
+
const profile = await researchSingleProfile(ctx, profileUrl, 2);
|
|
1166
|
+
enriched.push(profile);
|
|
1167
|
+
await sleep(1500);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
// Apply profile-level filters
|
|
1171
|
+
if (enriched.length > 0 && (minFollowers != null || requireWebsite)) {
|
|
1172
|
+
enriched = enriched.filter((p) => {
|
|
1173
|
+
if (!p.data)
|
|
1174
|
+
return false;
|
|
1175
|
+
const text = JSON.stringify(p.data).toLowerCase();
|
|
1176
|
+
if (minFollowers != null) {
|
|
1177
|
+
// Try to find follower count in data
|
|
1178
|
+
const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
|
|
1179
|
+
if (followerMatch) {
|
|
1180
|
+
const count = parseInt(followerMatch[1].replace(/,/g, ""), 10);
|
|
1181
|
+
if (count < minFollowers)
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (requireWebsite) {
|
|
1186
|
+
if (!text.includes("http") && !text.includes("www.") && !text.includes(".com"))
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
// Close tab
|
|
1193
|
+
try {
|
|
1194
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
1195
|
+
}
|
|
1196
|
+
catch { /* ok */ }
|
|
1197
|
+
return {
|
|
1198
|
+
url,
|
|
1199
|
+
query: query ?? null,
|
|
1200
|
+
total_results: items.length,
|
|
1201
|
+
profiles_enriched: enriched.length,
|
|
1202
|
+
filters_applied: {
|
|
1203
|
+
keywords: keywords.length > 0 ? keywords : null,
|
|
1204
|
+
min_followers: minFollowers ?? null,
|
|
1205
|
+
require_website: requireWebsite,
|
|
1206
|
+
},
|
|
1207
|
+
results: items.slice(0, 30),
|
|
1208
|
+
profiles: enriched,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
async function demandScanner(args, ctx) {
|
|
1212
|
+
const url = args.url;
|
|
1213
|
+
const triggers = args.triggers;
|
|
1214
|
+
const scrollCount = args.scrolls ?? 5;
|
|
1215
|
+
const schedule = args.schedule ?? false;
|
|
1216
|
+
const intervalMin = args.check_interval_minutes ?? 120;
|
|
1217
|
+
const enrichProfiles = args.enrich_profiles ?? false;
|
|
1218
|
+
// Open the feed
|
|
1219
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1220
|
+
const tabId = tab?.tabId;
|
|
1221
|
+
if (!tabId)
|
|
1222
|
+
throw new Error("Failed to open tab");
|
|
1223
|
+
await sleep(3000);
|
|
1224
|
+
// Scroll to collect posts
|
|
1225
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
1226
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
|
|
1227
|
+
await sleep(700);
|
|
1228
|
+
}
|
|
1229
|
+
// Extract posts
|
|
1230
|
+
const rawData = (await call(ctx, "extractData", { tabId }));
|
|
1231
|
+
const items = Array.isArray(rawData)
|
|
1232
|
+
? rawData
|
|
1233
|
+
: rawData?.items ?? [rawData];
|
|
1234
|
+
// Match against triggers
|
|
1235
|
+
const matched = filterByKeywords(items, triggers);
|
|
1236
|
+
// Enrich matched authors
|
|
1237
|
+
let enriched = [];
|
|
1238
|
+
if (enrichProfiles && matched.length > 0) {
|
|
1239
|
+
const authorUrls = new Set();
|
|
1240
|
+
for (const item of matched) {
|
|
1241
|
+
const obj = item;
|
|
1242
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
1243
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
1244
|
+
for (const link of linkList) {
|
|
1245
|
+
if (typeof link === "string" && link.startsWith("http")) {
|
|
1246
|
+
authorUrls.add(link);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
for (const profileUrl of [...authorUrls].slice(0, 10)) {
|
|
1251
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
1252
|
+
await sleep(1500);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// Close tab
|
|
1256
|
+
try {
|
|
1257
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
1258
|
+
}
|
|
1259
|
+
catch { /* ok */ }
|
|
1260
|
+
// Create recurring monitor if scheduled
|
|
1261
|
+
let monitorId = null;
|
|
1262
|
+
if (schedule) {
|
|
1263
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
1264
|
+
const store = loadYaml(monitorsFile) ?? { monitors: [] };
|
|
1265
|
+
monitorId = `ds-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1266
|
+
store.monitors.push({
|
|
1267
|
+
id: monitorId,
|
|
1268
|
+
url,
|
|
1269
|
+
name: `Demand Scanner: ${triggers.slice(0, 3).join(", ")}`,
|
|
1270
|
+
check_interval_minutes: intervalMin,
|
|
1271
|
+
condition: {
|
|
1272
|
+
type: "new_items",
|
|
1273
|
+
metadata: { triggers, source: "demand_scanner" },
|
|
1274
|
+
},
|
|
1275
|
+
enabled: true,
|
|
1276
|
+
created_at: new Date().toISOString(),
|
|
1277
|
+
alert_count: 0,
|
|
1278
|
+
});
|
|
1279
|
+
saveYaml(monitorsFile, store);
|
|
1280
|
+
}
|
|
1281
|
+
return {
|
|
1282
|
+
url,
|
|
1283
|
+
platform: detectPlatform(url),
|
|
1284
|
+
total_posts: items.length,
|
|
1285
|
+
trigger_matches: matched.length,
|
|
1286
|
+
triggers,
|
|
1287
|
+
matches: matched.slice(0, 30),
|
|
1288
|
+
enriched_profiles: enriched.length > 0 ? enriched : undefined,
|
|
1289
|
+
monitor: monitorId
|
|
1290
|
+
? { id: monitorId, check_interval_minutes: intervalMin, status: "active" }
|
|
1291
|
+
: undefined,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
async function opportunityRadar(args) {
|
|
1295
|
+
const limit = args.limit ?? 50;
|
|
1296
|
+
const sinceHours = args.since_hours ?? 24;
|
|
1297
|
+
const keywords = args.keywords ?? [];
|
|
1298
|
+
const alertsFile = path.join(NEURON_DIR, "monitor-alerts.yaml");
|
|
1299
|
+
const store = loadYaml(alertsFile);
|
|
1300
|
+
let alerts = store?.alerts ?? [];
|
|
1301
|
+
// Filter by time
|
|
1302
|
+
const cutoff = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString();
|
|
1303
|
+
alerts = alerts.filter((a) => {
|
|
1304
|
+
const alert = a;
|
|
1305
|
+
return alert.triggered_at >= cutoff;
|
|
1306
|
+
});
|
|
1307
|
+
// Filter by keywords
|
|
1308
|
+
if (keywords.length > 0) {
|
|
1309
|
+
alerts = filterByKeywords(alerts, keywords);
|
|
1310
|
+
}
|
|
1311
|
+
// Sort by recency (newest first)
|
|
1312
|
+
alerts.sort((a, b) => {
|
|
1313
|
+
const aTime = a.triggered_at;
|
|
1314
|
+
const bTime = b.triggered_at;
|
|
1315
|
+
return bTime.localeCompare(aTime);
|
|
1316
|
+
});
|
|
1317
|
+
// Deduplicate by URL + condition type
|
|
1318
|
+
const seen = new Set();
|
|
1319
|
+
alerts = alerts.filter((a) => {
|
|
1320
|
+
const alert = a;
|
|
1321
|
+
const key = `${alert.url}:${alert.condition_type}`;
|
|
1322
|
+
if (seen.has(key))
|
|
1323
|
+
return false;
|
|
1324
|
+
seen.add(key);
|
|
1325
|
+
return true;
|
|
1326
|
+
});
|
|
1327
|
+
// Load monitor names for context
|
|
1328
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
1329
|
+
const monitorStore = loadYaml(monitorsFile);
|
|
1330
|
+
const monitorNames = new Map();
|
|
1331
|
+
for (const m of monitorStore?.monitors ?? []) {
|
|
1332
|
+
monitorNames.set(m.id, m.name);
|
|
1333
|
+
}
|
|
1334
|
+
const enrichedAlerts = alerts.slice(0, limit).map((a) => {
|
|
1335
|
+
const alert = a;
|
|
1336
|
+
return {
|
|
1337
|
+
...alert,
|
|
1338
|
+
monitor_name: alert.monitor_name ?? monitorNames.get(alert.monitor_id) ?? "unknown",
|
|
1339
|
+
};
|
|
1340
|
+
});
|
|
1341
|
+
return {
|
|
1342
|
+
total_alerts: alerts.length,
|
|
1343
|
+
showing: enrichedAlerts.length,
|
|
1344
|
+
since_hours: sinceHours,
|
|
1345
|
+
keywords_applied: keywords,
|
|
1346
|
+
opportunities: enrichedAlerts,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
// ── Phase 4 ─────────────────────────────────────────────────
|
|
1350
|
+
async function quickAction(args, ctx) {
|
|
1351
|
+
const action = args.action;
|
|
1352
|
+
const text = args.text;
|
|
1353
|
+
let tabId = args.tabId;
|
|
1354
|
+
const url = args.url;
|
|
1355
|
+
// Navigate if URL provided
|
|
1356
|
+
if (url && !tabId) {
|
|
1357
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1358
|
+
tabId = tab?.tabId;
|
|
1359
|
+
if (!tabId)
|
|
1360
|
+
throw new Error("Failed to open tab");
|
|
1361
|
+
await sleep(3000);
|
|
1362
|
+
}
|
|
1363
|
+
if (!tabId)
|
|
1364
|
+
throw new Error("Either tabId or url is required");
|
|
1365
|
+
// Detect platform
|
|
1366
|
+
let platform = args.platform;
|
|
1367
|
+
if (!platform) {
|
|
1368
|
+
try {
|
|
1369
|
+
const tabUrl = (await call(ctx, "evaluateJS", {
|
|
1370
|
+
tabId,
|
|
1371
|
+
expression: "window.location.href",
|
|
1372
|
+
}));
|
|
1373
|
+
platform = detectPlatform(tabUrl);
|
|
1374
|
+
}
|
|
1375
|
+
catch {
|
|
1376
|
+
platform = "web";
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
// Focus the tab
|
|
1380
|
+
await call(ctx, "focusTab", { tabId });
|
|
1381
|
+
await sleep(300);
|
|
1382
|
+
// Get platform selectors
|
|
1383
|
+
const selectors = PLATFORM_SELECTORS[platform] ?? {};
|
|
1384
|
+
const actionMap = {
|
|
1385
|
+
like: "like",
|
|
1386
|
+
comment: "comment",
|
|
1387
|
+
reply: "reply",
|
|
1388
|
+
follow: "follow",
|
|
1389
|
+
dm: "dm",
|
|
1390
|
+
compose: "compose",
|
|
1391
|
+
};
|
|
1392
|
+
const selectorKey = actionMap[action] ?? action;
|
|
1393
|
+
const targetSelectors = selectors[selectorKey] ?? [];
|
|
1394
|
+
if (targetSelectors.length === 0) {
|
|
1395
|
+
return {
|
|
1396
|
+
status: "unsupported",
|
|
1397
|
+
action,
|
|
1398
|
+
platform,
|
|
1399
|
+
message: `No selectors registered for "${action}" on platform "${platform}"`,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
// For actions that just need a click (like, follow)
|
|
1403
|
+
if (action === "like" || action === "follow") {
|
|
1404
|
+
try {
|
|
1405
|
+
await call(ctx, "clickElement", { tabId, selectors: targetSelectors });
|
|
1406
|
+
await sleep(500);
|
|
1407
|
+
return { status: "success", action, platform, tabId };
|
|
1408
|
+
}
|
|
1409
|
+
catch (err) {
|
|
1410
|
+
return { status: "error", action, platform, error: err.message };
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// For actions that need text input (comment, reply, dm, compose)
|
|
1414
|
+
if (!text) {
|
|
1415
|
+
return {
|
|
1416
|
+
status: "text_required",
|
|
1417
|
+
action,
|
|
1418
|
+
platform,
|
|
1419
|
+
message: `The "${action}" action requires a "text" parameter`,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
try {
|
|
1423
|
+
// Click the input area
|
|
1424
|
+
await call(ctx, "clickElement", { tabId, selectors: targetSelectors });
|
|
1425
|
+
await sleep(500);
|
|
1426
|
+
// Type the text
|
|
1427
|
+
await call(ctx, "typeText", { tabId, selectors: targetSelectors, value: text });
|
|
1428
|
+
await sleep(500);
|
|
1429
|
+
return {
|
|
1430
|
+
status: "typed",
|
|
1431
|
+
action,
|
|
1432
|
+
platform,
|
|
1433
|
+
tabId,
|
|
1434
|
+
message: `Text typed into ${action} field. Use neuron_smart_click to find and click the submit/send button.`,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
return { status: "error", action, platform, error: err.message };
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
async function patternLearner(args, ctx) {
|
|
1442
|
+
const tabId = args.tabId;
|
|
1443
|
+
const examples = args.examples;
|
|
1444
|
+
const patternName = args.name;
|
|
1445
|
+
const shouldExtract = args.extract ?? true;
|
|
1446
|
+
if (examples.length < 2) {
|
|
1447
|
+
throw new Error("At least 2 example text values are required");
|
|
1448
|
+
}
|
|
1449
|
+
// Use evaluateJS to find DOM elements matching example text and infer a common selector
|
|
1450
|
+
const result = await call(ctx, "evaluateJS", {
|
|
1451
|
+
tabId,
|
|
1452
|
+
expression: `
|
|
1453
|
+
(function() {
|
|
1454
|
+
const examples = ${JSON.stringify(examples)};
|
|
1455
|
+
const matchingElements = [];
|
|
1456
|
+
|
|
1457
|
+
// Find elements containing each example text
|
|
1458
|
+
for (const example of examples) {
|
|
1459
|
+
const walker = document.createTreeWalker(
|
|
1460
|
+
document.body,
|
|
1461
|
+
NodeFilter.SHOW_ELEMENT,
|
|
1462
|
+
null
|
|
1463
|
+
);
|
|
1464
|
+
let node;
|
|
1465
|
+
while (node = walker.nextNode()) {
|
|
1466
|
+
const el = node;
|
|
1467
|
+
if (el.textContent && el.textContent.trim().includes(example.trim())) {
|
|
1468
|
+
// Prefer the deepest element that contains the text
|
|
1469
|
+
let deepest = el;
|
|
1470
|
+
for (const child of el.querySelectorAll('*')) {
|
|
1471
|
+
if (child.textContent && child.textContent.trim().includes(example.trim()) && child.children.length < el.children.length) {
|
|
1472
|
+
deepest = child;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
matchingElements.push({
|
|
1476
|
+
tag: deepest.tagName.toLowerCase(),
|
|
1477
|
+
classes: [...deepest.classList],
|
|
1478
|
+
id: deepest.id || null,
|
|
1479
|
+
parentTag: deepest.parentElement?.tagName.toLowerCase() || null,
|
|
1480
|
+
parentClasses: deepest.parentElement ? [...deepest.parentElement.classList] : [],
|
|
1481
|
+
});
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (matchingElements.length < 2) {
|
|
1488
|
+
return JSON.stringify({ error: 'Could not find matching elements for all examples', found: matchingElements.length });
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Find common ancestor pattern
|
|
1492
|
+
const tags = matchingElements.map(e => e.tag);
|
|
1493
|
+
const commonTag = tags.every(t => t === tags[0]) ? tags[0] : '*';
|
|
1494
|
+
|
|
1495
|
+
// Find common classes
|
|
1496
|
+
const classSets = matchingElements.map(e => new Set(e.classes));
|
|
1497
|
+
const commonClasses = [...classSets[0]].filter(c =>
|
|
1498
|
+
classSets.every(s => s.has(c))
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
// Find common parent pattern
|
|
1502
|
+
const parentTags = matchingElements.map(e => e.parentTag);
|
|
1503
|
+
const commonParentTag = parentTags.every(t => t === parentTags[0]) ? parentTags[0] : null;
|
|
1504
|
+
const parentClassSets = matchingElements.map(e => new Set(e.parentClasses));
|
|
1505
|
+
const commonParentClasses = parentClassSets[0] ? [...parentClassSets[0]].filter(c =>
|
|
1506
|
+
parentClassSets.every(s => s.has(c))
|
|
1507
|
+
) : [];
|
|
1508
|
+
|
|
1509
|
+
// Build selector
|
|
1510
|
+
let selector = commonTag;
|
|
1511
|
+
if (commonClasses.length > 0) {
|
|
1512
|
+
selector = commonTag + '.' + commonClasses.join('.');
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
let parentSelector = null;
|
|
1516
|
+
if (commonParentTag) {
|
|
1517
|
+
parentSelector = commonParentTag;
|
|
1518
|
+
if (commonParentClasses.length > 0) {
|
|
1519
|
+
parentSelector += '.' + commonParentClasses.join('.');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Count how many elements match
|
|
1524
|
+
const fullSelector = parentSelector ? parentSelector + ' > ' + selector : selector;
|
|
1525
|
+
const matchCount = document.querySelectorAll(fullSelector).length;
|
|
1526
|
+
|
|
1527
|
+
return JSON.stringify({
|
|
1528
|
+
selector: fullSelector,
|
|
1529
|
+
item_selector: selector,
|
|
1530
|
+
parent_selector: parentSelector,
|
|
1531
|
+
match_count: matchCount,
|
|
1532
|
+
elements: matchingElements,
|
|
1533
|
+
});
|
|
1534
|
+
})()
|
|
1535
|
+
`,
|
|
1536
|
+
});
|
|
1537
|
+
const parsed = JSON.parse(String(result ?? "{}"));
|
|
1538
|
+
if (parsed.error) {
|
|
1539
|
+
return { status: "failed", error: parsed.error };
|
|
1540
|
+
}
|
|
1541
|
+
const inferredSelector = parsed.selector;
|
|
1542
|
+
// Save pattern if named
|
|
1543
|
+
if (patternName && inferredSelector) {
|
|
1544
|
+
const extractorsFile = path.join(NEURON_DIR, "extractors.yaml");
|
|
1545
|
+
const extractors = loadYaml(extractorsFile) ?? {};
|
|
1546
|
+
extractors[patternName] = {
|
|
1547
|
+
selector: inferredSelector,
|
|
1548
|
+
item_selector: parsed.item_selector,
|
|
1549
|
+
parent_selector: parsed.parent_selector,
|
|
1550
|
+
examples,
|
|
1551
|
+
learned_at: new Date().toISOString(),
|
|
1552
|
+
};
|
|
1553
|
+
saveYaml(extractorsFile, extractors);
|
|
1554
|
+
}
|
|
1555
|
+
// Extract if requested
|
|
1556
|
+
let extracted = null;
|
|
1557
|
+
if (shouldExtract && inferredSelector) {
|
|
1558
|
+
extracted = await call(ctx, "extractData", { tabId, selector: inferredSelector });
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
status: "success",
|
|
1562
|
+
pattern_name: patternName ?? null,
|
|
1563
|
+
selector: inferredSelector,
|
|
1564
|
+
match_count: parsed.match_count,
|
|
1565
|
+
saved: !!patternName,
|
|
1566
|
+
extracted: extracted,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
// ── Phase 5 ─────────────────────────────────────────────────
|
|
1570
|
+
async function trafficReplay(args, ctx) {
|
|
1571
|
+
const mode = args.mode;
|
|
1572
|
+
const name = args.name;
|
|
1573
|
+
const replaysDir = path.join(NEURON_DIR, "replays");
|
|
1574
|
+
ensureDir(replaysDir);
|
|
1575
|
+
switch (mode) {
|
|
1576
|
+
case "capture": {
|
|
1577
|
+
if (!name)
|
|
1578
|
+
throw new Error("Name is required for capture mode");
|
|
1579
|
+
const tabId = args.tabId;
|
|
1580
|
+
const platform = args.platform;
|
|
1581
|
+
const harArgs = { includeBody: true };
|
|
1582
|
+
if (tabId)
|
|
1583
|
+
harArgs.tabId = tabId;
|
|
1584
|
+
if (platform)
|
|
1585
|
+
harArgs.platform = platform;
|
|
1586
|
+
const har = await call(ctx, "exportHAR", harArgs);
|
|
1587
|
+
const filePath = path.join(replaysDir, `${name}.har.json`);
|
|
1588
|
+
fs.writeFileSync(filePath, JSON.stringify(har, null, 2), "utf-8");
|
|
1589
|
+
const entries = har?.log?.entries;
|
|
1590
|
+
const entryCount = Array.isArray(entries) ? entries.length : 0;
|
|
1591
|
+
return {
|
|
1592
|
+
status: "captured",
|
|
1593
|
+
name,
|
|
1594
|
+
file: filePath,
|
|
1595
|
+
entries: entryCount,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
case "replay": {
|
|
1599
|
+
if (!name)
|
|
1600
|
+
throw new Error("Name is required for replay mode");
|
|
1601
|
+
const filePath = path.join(replaysDir, `${name}.har.json`);
|
|
1602
|
+
if (!fs.existsSync(filePath)) {
|
|
1603
|
+
throw new Error(`Replay "${name}" not found at ${filePath}`);
|
|
1604
|
+
}
|
|
1605
|
+
const harContent = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
1606
|
+
const log = harContent.log;
|
|
1607
|
+
const entries = (log?.entries ?? []);
|
|
1608
|
+
// Set mock rules for each captured URL
|
|
1609
|
+
let rulesSet = 0;
|
|
1610
|
+
for (const entry of entries.slice(0, 50)) {
|
|
1611
|
+
const request = entry.request;
|
|
1612
|
+
const response = entry.response;
|
|
1613
|
+
const content = response?.content;
|
|
1614
|
+
const body = content?.text;
|
|
1615
|
+
if (request?.url && body) {
|
|
1616
|
+
try {
|
|
1617
|
+
await call(ctx, "setMockRule", {
|
|
1618
|
+
urlPattern: request.url,
|
|
1619
|
+
matchType: "glob",
|
|
1620
|
+
responseStatus: response?.status ?? 200,
|
|
1621
|
+
responseBody: body,
|
|
1622
|
+
});
|
|
1623
|
+
rulesSet++;
|
|
1624
|
+
}
|
|
1625
|
+
catch {
|
|
1626
|
+
// skip if mock fails
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Navigate if URL provided
|
|
1631
|
+
const navigateUrl = args.navigate_url;
|
|
1632
|
+
let tabId;
|
|
1633
|
+
if (navigateUrl) {
|
|
1634
|
+
const tab = (await call(ctx, "openTab", { url: navigateUrl }));
|
|
1635
|
+
tabId = tab?.tabId;
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
status: "replaying",
|
|
1639
|
+
name,
|
|
1640
|
+
mock_rules_set: rulesSet,
|
|
1641
|
+
total_entries: entries.length,
|
|
1642
|
+
tabId: tabId ?? null,
|
|
1643
|
+
message: `${rulesSet} mock rules set from "${name}". Traffic will be intercepted.`,
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
case "list": {
|
|
1647
|
+
const files = fs.existsSync(replaysDir)
|
|
1648
|
+
? fs.readdirSync(replaysDir).filter((f) => f.endsWith(".har.json"))
|
|
1649
|
+
: [];
|
|
1650
|
+
const replays = files.map((f) => {
|
|
1651
|
+
const stat = fs.statSync(path.join(replaysDir, f));
|
|
1652
|
+
return {
|
|
1653
|
+
name: f.replace(".har.json", ""),
|
|
1654
|
+
file: f,
|
|
1655
|
+
size_kb: Math.round(stat.size / 1024),
|
|
1656
|
+
captured_at: stat.mtime.toISOString(),
|
|
1657
|
+
};
|
|
1658
|
+
});
|
|
1659
|
+
return { replays, count: replays.length };
|
|
1660
|
+
}
|
|
1661
|
+
default:
|
|
1662
|
+
throw new Error(`Unknown traffic replay mode: ${mode}. Use 'capture', 'replay', or 'list'.`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
async function apiBlueprint(args, ctx) {
|
|
1666
|
+
const collectionName = args.collection_name;
|
|
1667
|
+
const url = args.url;
|
|
1668
|
+
const includeAuth = args.include_auth ?? true;
|
|
1669
|
+
// Navigate to URL if provided to capture fresh traffic
|
|
1670
|
+
let tabId = args.tabId;
|
|
1671
|
+
if (url) {
|
|
1672
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1673
|
+
tabId = tab?.tabId;
|
|
1674
|
+
if (!tabId)
|
|
1675
|
+
throw new Error("Failed to open tab");
|
|
1676
|
+
await sleep(3000);
|
|
1677
|
+
// Scroll to trigger API calls
|
|
1678
|
+
for (let i = 0; i < 3; i++) {
|
|
1679
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
1680
|
+
await sleep(500);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// Discover APIs
|
|
1684
|
+
const apis = (await call(ctx, "discoverApis", {}));
|
|
1685
|
+
// Get recent requests for auth headers and response details
|
|
1686
|
+
const requestArgs = { limit: 200 };
|
|
1687
|
+
if (tabId)
|
|
1688
|
+
requestArgs.tabId = tabId;
|
|
1689
|
+
const requests = (await call(ctx, "getRecentRequests", requestArgs));
|
|
1690
|
+
const requestList = (requests?.requests ?? []);
|
|
1691
|
+
// Build auth header map
|
|
1692
|
+
const authHeaders = {};
|
|
1693
|
+
if (includeAuth) {
|
|
1694
|
+
for (const req of requestList) {
|
|
1695
|
+
const headers = req.requestHeaders;
|
|
1696
|
+
if (headers) {
|
|
1697
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1698
|
+
const lk = key.toLowerCase();
|
|
1699
|
+
if (lk === "authorization" || lk === "x-api-key" || lk.includes("token") || lk.includes("csrf")) {
|
|
1700
|
+
authHeaders[key] = value;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
// Group endpoints by base URL
|
|
1707
|
+
const endpoints = (apis?.endpoints ?? apis?.api_map ?? []);
|
|
1708
|
+
const folders = new Map();
|
|
1709
|
+
for (const ep of endpoints) {
|
|
1710
|
+
const epUrl = String(ep.url ?? ep.pattern ?? "");
|
|
1711
|
+
try {
|
|
1712
|
+
const parsed = new URL(epUrl.startsWith("http") ? epUrl : `https://example.com${epUrl}`);
|
|
1713
|
+
const folder = parsed.hostname.replace(/\./g, "-");
|
|
1714
|
+
if (!folders.has(folder))
|
|
1715
|
+
folders.set(folder, []);
|
|
1716
|
+
folders.get(folder).push(ep);
|
|
1717
|
+
}
|
|
1718
|
+
catch {
|
|
1719
|
+
if (!folders.has("misc"))
|
|
1720
|
+
folders.set("misc", []);
|
|
1721
|
+
folders.get("misc").push(ep);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// Create the collection file
|
|
1725
|
+
const slug = collectionName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1726
|
+
const collectionsDir = path.join(NEURON_DIR, "collections");
|
|
1727
|
+
ensureDir(collectionsDir);
|
|
1728
|
+
const collection = {
|
|
1729
|
+
name: collectionName,
|
|
1730
|
+
description: `API blueprint auto-generated from traffic${url ? ` on ${url}` : ""}`,
|
|
1731
|
+
version: "1.0.0",
|
|
1732
|
+
created_at: new Date().toISOString(),
|
|
1733
|
+
defaults: {
|
|
1734
|
+
headers: includeAuth ? authHeaders : {},
|
|
1735
|
+
},
|
|
1736
|
+
folders: {},
|
|
1737
|
+
};
|
|
1738
|
+
const foldersObj = collection.folders;
|
|
1739
|
+
for (const [folderName, eps] of folders) {
|
|
1740
|
+
foldersObj[folderName] = {
|
|
1741
|
+
description: `Endpoints for ${folderName}`,
|
|
1742
|
+
endpoints: eps.map((ep) => ({
|
|
1743
|
+
method: String(ep.method ?? ep.methods ?? "GET").split(",")[0].trim().toUpperCase(),
|
|
1744
|
+
path: String(ep.url ?? ep.pattern ?? "/"),
|
|
1745
|
+
name: String(ep.name ?? ep.pattern ?? ep.url ?? "endpoint"),
|
|
1746
|
+
description: `${ep.call_count ?? 0} calls observed`,
|
|
1747
|
+
})),
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
saveYaml(path.join(collectionsDir, `${slug}.yaml`), collection);
|
|
1751
|
+
// Close tab if we opened one
|
|
1752
|
+
if (url && tabId) {
|
|
1753
|
+
try {
|
|
1754
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
1755
|
+
}
|
|
1756
|
+
catch { /* ok */ }
|
|
1757
|
+
}
|
|
1758
|
+
return {
|
|
1759
|
+
status: "created",
|
|
1760
|
+
collection_name: collectionName,
|
|
1761
|
+
collection_file: `${slug}.yaml`,
|
|
1762
|
+
total_endpoints: endpoints.length,
|
|
1763
|
+
folders: [...folders.entries()].map(([name, eps]) => ({
|
|
1764
|
+
name,
|
|
1765
|
+
endpoint_count: eps.length,
|
|
1766
|
+
})),
|
|
1767
|
+
auth_headers_found: Object.keys(authHeaders).length,
|
|
1768
|
+
source_url: url ?? "existing traffic",
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
// ── Wave 2 helper: extract posts from a page ────────────────
|
|
1772
|
+
async function extractPosts(ctx, url, scrollCount, selector) {
|
|
1773
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
1774
|
+
const tabId = tab?.tabId;
|
|
1775
|
+
if (!tabId)
|
|
1776
|
+
throw new Error("Failed to open tab");
|
|
1777
|
+
await sleep(3000);
|
|
1778
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
1779
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 800, smooth: true });
|
|
1780
|
+
await sleep(700);
|
|
1781
|
+
}
|
|
1782
|
+
const extractArgs = { tabId };
|
|
1783
|
+
if (selector)
|
|
1784
|
+
extractArgs.selector = selector;
|
|
1785
|
+
const rawData = (await call(ctx, "extractData", extractArgs));
|
|
1786
|
+
const items = Array.isArray(rawData)
|
|
1787
|
+
? rawData
|
|
1788
|
+
: rawData?.items ?? [rawData];
|
|
1789
|
+
return { tabId, items };
|
|
1790
|
+
}
|
|
1791
|
+
async function closeTab(ctx, tabId) {
|
|
1792
|
+
try {
|
|
1793
|
+
await call(ctx, "evaluateJS", { tabId, expression: "window.close()" });
|
|
1794
|
+
}
|
|
1795
|
+
catch { /* ok */ }
|
|
1796
|
+
}
|
|
1797
|
+
async function pageWatch(args) {
|
|
1798
|
+
const url = args.url;
|
|
1799
|
+
const name = args.name;
|
|
1800
|
+
const selector = args.selector;
|
|
1801
|
+
const intervalMin = args.check_interval_minutes ?? 60;
|
|
1802
|
+
const keywords = args.keywords ?? [];
|
|
1803
|
+
// Load existing monitors
|
|
1804
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
1805
|
+
const store = loadYaml(monitorsFile) ?? { monitors: [] };
|
|
1806
|
+
const monitorId = `pw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1807
|
+
const monitor = {
|
|
1808
|
+
id: monitorId,
|
|
1809
|
+
url,
|
|
1810
|
+
name,
|
|
1811
|
+
check_interval_minutes: intervalMin,
|
|
1812
|
+
condition: {
|
|
1813
|
+
type: "content_change",
|
|
1814
|
+
selector: selector ?? undefined,
|
|
1815
|
+
metadata: { keywords, source: "page_watch", use_structured_diff: true },
|
|
1816
|
+
},
|
|
1817
|
+
enabled: true,
|
|
1818
|
+
created_at: new Date().toISOString(),
|
|
1819
|
+
alert_count: 0,
|
|
1820
|
+
};
|
|
1821
|
+
store.monitors.push(monitor);
|
|
1822
|
+
saveYaml(monitorsFile, store);
|
|
1823
|
+
return {
|
|
1824
|
+
status: "created",
|
|
1825
|
+
monitor_id: monitorId,
|
|
1826
|
+
name,
|
|
1827
|
+
url,
|
|
1828
|
+
selector: selector ?? "full page",
|
|
1829
|
+
check_interval_minutes: intervalMin,
|
|
1830
|
+
keywords,
|
|
1831
|
+
message: `Page watch "${name}" created. It will check ${url} every ${intervalMin} minutes for content changes.`,
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
// ── Wave 2 implementations ──────────────────────────────────
|
|
1835
|
+
async function revenueTracker(args) {
|
|
1836
|
+
const action = args.action;
|
|
1837
|
+
const pipelineFile = path.join(NEURON_DIR, "pipeline.yaml");
|
|
1838
|
+
const pipeline = loadYaml(pipelineFile) ?? { leads: [] };
|
|
1839
|
+
switch (action) {
|
|
1840
|
+
case "add_lead": {
|
|
1841
|
+
const lead = args.lead;
|
|
1842
|
+
if (!lead)
|
|
1843
|
+
throw new Error("Lead data required for add_lead");
|
|
1844
|
+
const id = `lead-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1845
|
+
pipeline.leads.push({
|
|
1846
|
+
id,
|
|
1847
|
+
...lead,
|
|
1848
|
+
stage: "discovered",
|
|
1849
|
+
revenue: 0,
|
|
1850
|
+
created_at: new Date().toISOString(),
|
|
1851
|
+
updated_at: new Date().toISOString(),
|
|
1852
|
+
history: [{ stage: "discovered", at: new Date().toISOString() }],
|
|
1853
|
+
});
|
|
1854
|
+
saveYaml(pipelineFile, pipeline);
|
|
1855
|
+
return { status: "added", lead_id: id, stage: "discovered" };
|
|
1856
|
+
}
|
|
1857
|
+
case "update_stage": {
|
|
1858
|
+
const leadId = args.lead_id;
|
|
1859
|
+
const stage = args.stage;
|
|
1860
|
+
if (!leadId || !stage)
|
|
1861
|
+
throw new Error("lead_id and stage required");
|
|
1862
|
+
const lead = pipeline.leads.find((l) => l.id === leadId);
|
|
1863
|
+
if (!lead)
|
|
1864
|
+
throw new Error(`Lead ${leadId} not found`);
|
|
1865
|
+
lead.stage = stage;
|
|
1866
|
+
lead.updated_at = new Date().toISOString();
|
|
1867
|
+
const history = lead.history ?? [];
|
|
1868
|
+
history.push({ stage, at: new Date().toISOString() });
|
|
1869
|
+
lead.history = history;
|
|
1870
|
+
saveYaml(pipelineFile, pipeline);
|
|
1871
|
+
return { status: "updated", lead_id: leadId, stage };
|
|
1872
|
+
}
|
|
1873
|
+
case "tag_revenue": {
|
|
1874
|
+
const leadId = args.lead_id;
|
|
1875
|
+
const revenue = args.revenue;
|
|
1876
|
+
if (!leadId || revenue == null)
|
|
1877
|
+
throw new Error("lead_id and revenue required");
|
|
1878
|
+
const lead = pipeline.leads.find((l) => l.id === leadId);
|
|
1879
|
+
if (!lead)
|
|
1880
|
+
throw new Error(`Lead ${leadId} not found`);
|
|
1881
|
+
lead.revenue = revenue;
|
|
1882
|
+
lead.stage = "closed_won";
|
|
1883
|
+
lead.updated_at = new Date().toISOString();
|
|
1884
|
+
const history = lead.history ?? [];
|
|
1885
|
+
history.push({ stage: "closed_won", revenue, at: new Date().toISOString() });
|
|
1886
|
+
lead.history = history;
|
|
1887
|
+
saveYaml(pipelineFile, pipeline);
|
|
1888
|
+
return { status: "revenue_tagged", lead_id: leadId, revenue };
|
|
1889
|
+
}
|
|
1890
|
+
case "list": {
|
|
1891
|
+
const filters = (args.filters ?? {});
|
|
1892
|
+
let leads = pipeline.leads;
|
|
1893
|
+
if (filters.stage)
|
|
1894
|
+
leads = leads.filter((l) => l.stage === filters.stage);
|
|
1895
|
+
if (filters.source)
|
|
1896
|
+
leads = leads.filter((l) => String(l.source_tool ?? l.source ?? "").includes(filters.source));
|
|
1897
|
+
if (filters.platform)
|
|
1898
|
+
leads = leads.filter((l) => l.platform === filters.platform);
|
|
1899
|
+
if (filters.since_days) {
|
|
1900
|
+
const cutoff = new Date(Date.now() - filters.since_days * 86400000).toISOString();
|
|
1901
|
+
leads = leads.filter((l) => l.created_at >= cutoff);
|
|
1902
|
+
}
|
|
1903
|
+
return { total: leads.length, leads };
|
|
1904
|
+
}
|
|
1905
|
+
case "stats": {
|
|
1906
|
+
const stages = {};
|
|
1907
|
+
const sources = {};
|
|
1908
|
+
let totalRevenue = 0;
|
|
1909
|
+
for (const lead of pipeline.leads) {
|
|
1910
|
+
const s = lead.stage;
|
|
1911
|
+
stages[s] = (stages[s] ?? 0) + 1;
|
|
1912
|
+
const src = String(lead.source_tool ?? lead.source ?? "unknown");
|
|
1913
|
+
sources[src] = (sources[src] ?? 0) + 1;
|
|
1914
|
+
totalRevenue += lead.revenue ?? 0;
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
total_leads: pipeline.leads.length,
|
|
1918
|
+
by_stage: stages,
|
|
1919
|
+
by_source: sources,
|
|
1920
|
+
total_revenue: totalRevenue,
|
|
1921
|
+
conversion_rate: pipeline.leads.length > 0
|
|
1922
|
+
? `${Math.round((stages.closed_won ?? 0) / pipeline.leads.length * 100)}%`
|
|
1923
|
+
: "0%",
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
default:
|
|
1927
|
+
throw new Error(`Unknown revenue tracker action: ${action}`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
async function leadScorer(args) {
|
|
1931
|
+
const leads = args.leads;
|
|
1932
|
+
const rubric = (args.rubric ?? {});
|
|
1933
|
+
const hotThreshold = args.hot_threshold ?? 80;
|
|
1934
|
+
const warmThreshold = args.warm_threshold ?? 50;
|
|
1935
|
+
const bioKeywords = rubric.bio_keywords ?? ["founder", "ceo", "business", "owner", "entrepreneur"];
|
|
1936
|
+
const locationKeywords = rubric.location_keywords ?? [];
|
|
1937
|
+
const followerMin = rubric.follower_min ?? 0;
|
|
1938
|
+
const followerMax = rubric.follower_max ?? Infinity;
|
|
1939
|
+
const requireWebsite = rubric.require_website ?? false;
|
|
1940
|
+
const maxDaysSincePost = rubric.max_days_since_post;
|
|
1941
|
+
const scored = leads.map((lead) => {
|
|
1942
|
+
let score = 50; // base score
|
|
1943
|
+
const text = JSON.stringify(lead).toLowerCase();
|
|
1944
|
+
const breakdown = {};
|
|
1945
|
+
// Bio keyword matching (+5 per match, max 25)
|
|
1946
|
+
let bioMatch = 0;
|
|
1947
|
+
for (const kw of bioKeywords) {
|
|
1948
|
+
if (text.includes(kw.toLowerCase()))
|
|
1949
|
+
bioMatch++;
|
|
1950
|
+
}
|
|
1951
|
+
const bioScore = Math.min(bioMatch * 5, 25);
|
|
1952
|
+
score += bioScore;
|
|
1953
|
+
breakdown.bio_keywords = bioScore;
|
|
1954
|
+
// Follower count
|
|
1955
|
+
const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
|
|
1956
|
+
if (followerMatch) {
|
|
1957
|
+
const count = parseInt(followerMatch[1].replace(/,/g, ""), 10);
|
|
1958
|
+
if (count >= followerMin && count <= followerMax) {
|
|
1959
|
+
const followerScore = Math.min(Math.floor(count / 1000), 15);
|
|
1960
|
+
score += followerScore;
|
|
1961
|
+
breakdown.followers = followerScore;
|
|
1962
|
+
}
|
|
1963
|
+
else {
|
|
1964
|
+
score -= 20;
|
|
1965
|
+
breakdown.followers = -20;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
// Website presence
|
|
1969
|
+
if (text.includes("http") || text.includes("www.") || text.includes(".com")) {
|
|
1970
|
+
score += 10;
|
|
1971
|
+
breakdown.website = 10;
|
|
1972
|
+
}
|
|
1973
|
+
else if (requireWebsite) {
|
|
1974
|
+
score -= 30;
|
|
1975
|
+
breakdown.website = -30;
|
|
1976
|
+
}
|
|
1977
|
+
// Location match
|
|
1978
|
+
if (locationKeywords.length > 0) {
|
|
1979
|
+
const locMatch = locationKeywords.some((l) => text.includes(l.toLowerCase()));
|
|
1980
|
+
if (locMatch) {
|
|
1981
|
+
score += 10;
|
|
1982
|
+
breakdown.location = 10;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
// Engagement signals
|
|
1986
|
+
if (text.includes("engaged") || text.includes("active")) {
|
|
1987
|
+
score += 5;
|
|
1988
|
+
breakdown.engagement = 5;
|
|
1989
|
+
}
|
|
1990
|
+
// Clamp score
|
|
1991
|
+
score = Math.max(0, Math.min(100, score));
|
|
1992
|
+
const tier = score >= hotThreshold ? "hot" : score >= warmThreshold ? "warm" : "cold";
|
|
1993
|
+
const recommended = tier === "hot" ? "DM immediately"
|
|
1994
|
+
: tier === "warm" ? "Comment/engage first"
|
|
1995
|
+
: "Follow and nurture";
|
|
1996
|
+
return { ...lead, score, tier, recommended_action: recommended, score_breakdown: breakdown };
|
|
1997
|
+
});
|
|
1998
|
+
// Sort by score descending
|
|
1999
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2000
|
+
return {
|
|
2001
|
+
total: scored.length,
|
|
2002
|
+
hot: scored.filter((s) => s.tier === "hot").length,
|
|
2003
|
+
warm: scored.filter((s) => s.tier === "warm").length,
|
|
2004
|
+
cold: scored.filter((s) => s.tier === "cold").length,
|
|
2005
|
+
thresholds: { hot: hotThreshold, warm: warmThreshold },
|
|
2006
|
+
leads: scored,
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
async function socialProof(args, ctx) {
|
|
2010
|
+
const action = args.action;
|
|
2011
|
+
const testimonialsFile = path.join(NEURON_DIR, "testimonials.yaml");
|
|
2012
|
+
switch (action) {
|
|
2013
|
+
case "scan": {
|
|
2014
|
+
const url = args.url;
|
|
2015
|
+
if (!url)
|
|
2016
|
+
throw new Error("URL required for scan action");
|
|
2017
|
+
const brandKeywords = args.brand_keywords ?? [];
|
|
2018
|
+
const positiveSignals = args.positive_signals ?? [
|
|
2019
|
+
"changed my", "love this", "best", "recommend", "game changer",
|
|
2020
|
+
"amazing", "thank you", "incredible", "life saver", "must have",
|
|
2021
|
+
];
|
|
2022
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2023
|
+
const { tabId, items } = await extractPosts(ctx, url, scrollCount);
|
|
2024
|
+
// Filter for positive mentions
|
|
2025
|
+
const allSignals = [...positiveSignals, ...brandKeywords];
|
|
2026
|
+
const matches = filterByKeywords(items, allSignals);
|
|
2027
|
+
// Save testimonials
|
|
2028
|
+
const store = loadYaml(testimonialsFile) ?? { testimonials: [] };
|
|
2029
|
+
for (const match of matches) {
|
|
2030
|
+
store.testimonials.push({
|
|
2031
|
+
...match,
|
|
2032
|
+
source_url: url,
|
|
2033
|
+
captured_at: new Date().toISOString(),
|
|
2034
|
+
matched_signals: allSignals.filter((s) => JSON.stringify(match).toLowerCase().includes(s.toLowerCase())),
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
saveYaml(testimonialsFile, store);
|
|
2038
|
+
await closeTab(ctx, tabId);
|
|
2039
|
+
return {
|
|
2040
|
+
url,
|
|
2041
|
+
total_scanned: items.length,
|
|
2042
|
+
testimonials_found: matches.length,
|
|
2043
|
+
saved: matches.length,
|
|
2044
|
+
testimonials: matches.slice(0, 20),
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
case "list": {
|
|
2048
|
+
const store = loadYaml(testimonialsFile);
|
|
2049
|
+
const testimonials = store?.testimonials ?? [];
|
|
2050
|
+
return { total: testimonials.length, testimonials: testimonials.slice(-50) };
|
|
2051
|
+
}
|
|
2052
|
+
case "export": {
|
|
2053
|
+
const store = loadYaml(testimonialsFile);
|
|
2054
|
+
return { testimonials: store?.testimonials ?? [], format: "json" };
|
|
2055
|
+
}
|
|
2056
|
+
default:
|
|
2057
|
+
throw new Error(`Unknown social proof action: ${action}`);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
async function dmOutreach(args, ctx) {
|
|
2061
|
+
const action = args.action;
|
|
2062
|
+
const campaignsFile = path.join(NEURON_DIR, "outreach-campaigns.yaml");
|
|
2063
|
+
switch (action) {
|
|
2064
|
+
case "preview": {
|
|
2065
|
+
const template = args.template;
|
|
2066
|
+
const leads = args.leads?.slice(0, 3) ?? [];
|
|
2067
|
+
if (!template)
|
|
2068
|
+
throw new Error("Template required for preview");
|
|
2069
|
+
const previews = leads.map((lead) => {
|
|
2070
|
+
let msg = template;
|
|
2071
|
+
for (const [key, value] of Object.entries(lead)) {
|
|
2072
|
+
msg = msg.replace(new RegExp(`\\{${key}\\}`, "g"), String(value ?? ""));
|
|
2073
|
+
}
|
|
2074
|
+
return { lead, message: msg };
|
|
2075
|
+
});
|
|
2076
|
+
return { action: "preview", previews };
|
|
2077
|
+
}
|
|
2078
|
+
case "start": {
|
|
2079
|
+
const template = args.template;
|
|
2080
|
+
const leads = args.leads;
|
|
2081
|
+
const platform = args.platform;
|
|
2082
|
+
const delayMin = args.delay_minutes ?? 5;
|
|
2083
|
+
const maxPerDay = args.max_per_day ?? 20;
|
|
2084
|
+
if (!template || !leads?.length || !platform) {
|
|
2085
|
+
throw new Error("template, leads, and platform required");
|
|
2086
|
+
}
|
|
2087
|
+
const campaignId = `dm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2088
|
+
const campaigns = loadYaml(campaignsFile) ?? { campaigns: [] };
|
|
2089
|
+
campaigns.campaigns.push({
|
|
2090
|
+
id: campaignId,
|
|
2091
|
+
platform,
|
|
2092
|
+
template,
|
|
2093
|
+
total_leads: leads.length,
|
|
2094
|
+
delay_minutes: delayMin,
|
|
2095
|
+
max_per_day: maxPerDay,
|
|
2096
|
+
status: "active",
|
|
2097
|
+
sent: 0,
|
|
2098
|
+
replied: 0,
|
|
2099
|
+
created_at: new Date().toISOString(),
|
|
2100
|
+
leads: leads.map((l) => ({ ...l, status: "queued" })),
|
|
2101
|
+
});
|
|
2102
|
+
saveYaml(campaignsFile, campaigns);
|
|
2103
|
+
// Send first message
|
|
2104
|
+
const firstLead = leads[0];
|
|
2105
|
+
let msg = template;
|
|
2106
|
+
for (const [key, value] of Object.entries(firstLead)) {
|
|
2107
|
+
msg = msg.replace(new RegExp(`\\{${key}\\}`, "g"), String(value ?? ""));
|
|
2108
|
+
}
|
|
2109
|
+
const selectors = PLATFORM_SELECTORS[platform]?.dm ?? [];
|
|
2110
|
+
if (selectors.length > 0 && firstLead.url) {
|
|
2111
|
+
try {
|
|
2112
|
+
const tab = (await call(ctx, "openTab", { url: firstLead.url }));
|
|
2113
|
+
if (tab?.tabId) {
|
|
2114
|
+
await sleep(3000);
|
|
2115
|
+
await call(ctx, "focusTab", { tabId: tab.tabId });
|
|
2116
|
+
await sleep(300);
|
|
2117
|
+
// Navigate to DM
|
|
2118
|
+
await call(ctx, "clickElement", { tabId: tab.tabId, selectors });
|
|
2119
|
+
await sleep(1000);
|
|
2120
|
+
await call(ctx, "typeText", { tabId: tab.tabId, selectors, value: msg });
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
catch {
|
|
2124
|
+
// first message send failed, campaign still created
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return {
|
|
2128
|
+
status: "started",
|
|
2129
|
+
campaign_id: campaignId,
|
|
2130
|
+
total_leads: leads.length,
|
|
2131
|
+
delay_minutes: delayMin,
|
|
2132
|
+
first_message: msg,
|
|
2133
|
+
message: `Campaign started. ${leads.length} leads queued. Sending 1 every ${delayMin} minutes (max ${maxPerDay}/day).`,
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
case "status": {
|
|
2137
|
+
const campaignId = args.campaign_id;
|
|
2138
|
+
const campaigns = loadYaml(campaignsFile);
|
|
2139
|
+
if (!campaignId) {
|
|
2140
|
+
return { campaigns: campaigns?.campaigns?.map((c) => ({ id: c.id, status: c.status, sent: c.sent, total: c.total_leads })) ?? [] };
|
|
2141
|
+
}
|
|
2142
|
+
const campaign = campaigns?.campaigns?.find((c) => c.id === campaignId);
|
|
2143
|
+
if (!campaign)
|
|
2144
|
+
throw new Error(`Campaign ${campaignId} not found`);
|
|
2145
|
+
return campaign;
|
|
2146
|
+
}
|
|
2147
|
+
case "pause": {
|
|
2148
|
+
const campaignId = args.campaign_id;
|
|
2149
|
+
if (!campaignId)
|
|
2150
|
+
throw new Error("campaign_id required");
|
|
2151
|
+
const campaigns = loadYaml(campaignsFile);
|
|
2152
|
+
const campaign = campaigns?.campaigns?.find((c) => c.id === campaignId);
|
|
2153
|
+
if (!campaign)
|
|
2154
|
+
throw new Error(`Campaign ${campaignId} not found`);
|
|
2155
|
+
campaign.status = "paused";
|
|
2156
|
+
saveYaml(campaignsFile, campaigns);
|
|
2157
|
+
return { status: "paused", campaign_id: campaignId };
|
|
2158
|
+
}
|
|
2159
|
+
default:
|
|
2160
|
+
throw new Error(`Unknown dm outreach action: ${action}`);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
async function postToLead(args, ctx) {
|
|
2164
|
+
const postUrl = args.post_url;
|
|
2165
|
+
const trigger = args.trigger;
|
|
2166
|
+
const enrichProfiles = args.enrich_profiles ?? true;
|
|
2167
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2168
|
+
const schedule = args.schedule ?? false;
|
|
2169
|
+
const intervalMin = args.check_interval_minutes ?? 30;
|
|
2170
|
+
// Open the post
|
|
2171
|
+
const { tabId, items } = await extractPosts(ctx, postUrl, scrollCount);
|
|
2172
|
+
// Find comments matching the trigger keyword
|
|
2173
|
+
const matched = filterByKeywords(items, [trigger]);
|
|
2174
|
+
// Enrich matched commenters
|
|
2175
|
+
let enriched = [];
|
|
2176
|
+
if (enrichProfiles && matched.length > 0) {
|
|
2177
|
+
const authorUrls = new Set();
|
|
2178
|
+
for (const item of matched) {
|
|
2179
|
+
const obj = item;
|
|
2180
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
2181
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
2182
|
+
for (const link of linkList) {
|
|
2183
|
+
if (typeof link === "string" && link.startsWith("http"))
|
|
2184
|
+
authorUrls.add(link);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
for (const profileUrl of [...authorUrls].slice(0, 20)) {
|
|
2188
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
2189
|
+
await sleep(1500);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
await closeTab(ctx, tabId);
|
|
2193
|
+
// Create monitor if scheduled
|
|
2194
|
+
let monitorId = null;
|
|
2195
|
+
if (schedule) {
|
|
2196
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
2197
|
+
const store = loadYaml(monitorsFile) ?? { monitors: [] };
|
|
2198
|
+
monitorId = `ptl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2199
|
+
store.monitors.push({
|
|
2200
|
+
id: monitorId,
|
|
2201
|
+
url: postUrl,
|
|
2202
|
+
name: `Post-to-Lead: "${trigger}" on ${postUrl.slice(0, 50)}`,
|
|
2203
|
+
check_interval_minutes: intervalMin,
|
|
2204
|
+
condition: { type: "new_items", metadata: { trigger, source: "post_to_lead" } },
|
|
2205
|
+
enabled: true,
|
|
2206
|
+
created_at: new Date().toISOString(),
|
|
2207
|
+
alert_count: 0,
|
|
2208
|
+
});
|
|
2209
|
+
saveYaml(monitorsFile, store);
|
|
2210
|
+
}
|
|
2211
|
+
return {
|
|
2212
|
+
post_url: postUrl,
|
|
2213
|
+
trigger,
|
|
2214
|
+
total_comments: items.length,
|
|
2215
|
+
trigger_matches: matched.length,
|
|
2216
|
+
leads: enriched.length > 0 ? enriched : matched.slice(0, 30),
|
|
2217
|
+
monitor: monitorId ? { id: monitorId, interval: intervalMin } : undefined,
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
async function commentMiner(args, ctx) {
|
|
2221
|
+
const postUrl = args.post_url;
|
|
2222
|
+
const keywords = args.keywords ?? [];
|
|
2223
|
+
const enrichProfiles = args.enrich_profiles ?? true;
|
|
2224
|
+
const maxProfiles = args.max_profiles ?? 20;
|
|
2225
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2226
|
+
const { tabId, items } = await extractPosts(ctx, postUrl, scrollCount);
|
|
2227
|
+
let filtered = items;
|
|
2228
|
+
if (keywords.length > 0) {
|
|
2229
|
+
filtered = filterByKeywords(items, keywords);
|
|
2230
|
+
}
|
|
2231
|
+
// Extract unique commenter URLs
|
|
2232
|
+
const authorUrls = new Set();
|
|
2233
|
+
for (const item of filtered) {
|
|
2234
|
+
const obj = item;
|
|
2235
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
2236
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
2237
|
+
for (const link of linkList) {
|
|
2238
|
+
if (typeof link === "string" && link.startsWith("http"))
|
|
2239
|
+
authorUrls.add(link);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
let enriched = [];
|
|
2243
|
+
if (enrichProfiles && authorUrls.size > 0) {
|
|
2244
|
+
for (const profileUrl of [...authorUrls].slice(0, maxProfiles)) {
|
|
2245
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
2246
|
+
await sleep(1500);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
await closeTab(ctx, tabId);
|
|
2250
|
+
return {
|
|
2251
|
+
post_url: postUrl,
|
|
2252
|
+
total_comments: items.length,
|
|
2253
|
+
filtered: filtered.length,
|
|
2254
|
+
keywords_applied: keywords,
|
|
2255
|
+
unique_commenters: authorUrls.size,
|
|
2256
|
+
comments: filtered.slice(0, 50),
|
|
2257
|
+
profiles: enriched,
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
async function competitorMapper(args, ctx) {
|
|
2261
|
+
const profileUrl = args.profile_url;
|
|
2262
|
+
const postsToScan = args.posts_to_scan ?? 5;
|
|
2263
|
+
const maxCommenters = args.max_commenters ?? 20;
|
|
2264
|
+
const scrollsPerPost = args.scrolls_per_post ?? 3;
|
|
2265
|
+
const yourHandle = args.your_handle;
|
|
2266
|
+
// Visit competitor profile
|
|
2267
|
+
const tab = (await call(ctx, "openTab", { url: profileUrl }));
|
|
2268
|
+
const tabId = tab?.tabId;
|
|
2269
|
+
if (!tabId)
|
|
2270
|
+
throw new Error("Failed to open tab");
|
|
2271
|
+
await sleep(3000);
|
|
2272
|
+
// Scroll to load posts
|
|
2273
|
+
for (let i = 0; i < 3; i++) {
|
|
2274
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
2275
|
+
await sleep(500);
|
|
2276
|
+
}
|
|
2277
|
+
// Extract post links
|
|
2278
|
+
const profileData = (await call(ctx, "extractData", { tabId }));
|
|
2279
|
+
const profileItems = Array.isArray(profileData)
|
|
2280
|
+
? profileData
|
|
2281
|
+
: profileData?.items ?? [profileData];
|
|
2282
|
+
// Find post URLs
|
|
2283
|
+
const postUrls = [];
|
|
2284
|
+
for (const item of profileItems) {
|
|
2285
|
+
const text = JSON.stringify(item);
|
|
2286
|
+
const urlMatches = text.match(/https?:\/\/[^\s"',]+\/p\/[^\s"',]+/g) ??
|
|
2287
|
+
text.match(/https?:\/\/[^\s"',]+\/status\/[^\s"',]+/g) ?? [];
|
|
2288
|
+
for (const u of urlMatches) {
|
|
2289
|
+
if (!postUrls.includes(u))
|
|
2290
|
+
postUrls.push(u);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
await closeTab(ctx, tabId);
|
|
2294
|
+
// Visit each post and extract commenters
|
|
2295
|
+
const commenterFrequency = new Map();
|
|
2296
|
+
const commenterData = new Map();
|
|
2297
|
+
for (const postUrl of postUrls.slice(0, postsToScan)) {
|
|
2298
|
+
try {
|
|
2299
|
+
const { tabId: postTabId, items } = await extractPosts(ctx, postUrl, scrollsPerPost);
|
|
2300
|
+
for (const item of items) {
|
|
2301
|
+
const obj = item;
|
|
2302
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
2303
|
+
const linkList = Array.isArray(links) ? links : [links];
|
|
2304
|
+
for (const link of linkList) {
|
|
2305
|
+
if (typeof link === "string" && link.startsWith("http")) {
|
|
2306
|
+
commenterFrequency.set(link, (commenterFrequency.get(link) ?? 0) + 1);
|
|
2307
|
+
if (!commenterData.has(link))
|
|
2308
|
+
commenterData.set(link, item);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
await closeTab(ctx, postTabId);
|
|
2313
|
+
await sleep(1500);
|
|
2314
|
+
}
|
|
2315
|
+
catch {
|
|
2316
|
+
// skip failed posts
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
// Sort by frequency and enrich top commenters
|
|
2320
|
+
const sorted = [...commenterFrequency.entries()]
|
|
2321
|
+
.sort((a, b) => b[1] - a[1])
|
|
2322
|
+
.slice(0, maxCommenters);
|
|
2323
|
+
const enriched = [];
|
|
2324
|
+
for (const [url, frequency] of sorted) {
|
|
2325
|
+
const profile = await researchSingleProfile(ctx, url, 2);
|
|
2326
|
+
const text = JSON.stringify(profile.data).toLowerCase();
|
|
2327
|
+
// Categorize
|
|
2328
|
+
let category = "casual_follower";
|
|
2329
|
+
if (text.includes("founder") || text.includes("ceo") || text.includes("business") || text.includes("owner")) {
|
|
2330
|
+
category = "potential_customer";
|
|
2331
|
+
}
|
|
2332
|
+
else if (text.includes("agency") || text.includes("marketing") || text.includes("consulting")) {
|
|
2333
|
+
category = "fellow_business";
|
|
2334
|
+
}
|
|
2335
|
+
else if (text.match(/(\d[\d,]*)\s*followers/) && parseInt((text.match(/(\d[\d,]*)\s*followers/) ?? ["0", "0"])[1].replace(/,/g, ""), 10) > 10000) {
|
|
2336
|
+
category = "influencer";
|
|
2337
|
+
}
|
|
2338
|
+
const isOverlap = yourHandle ? text.includes(yourHandle.toLowerCase()) : false;
|
|
2339
|
+
enriched.push({
|
|
2340
|
+
url,
|
|
2341
|
+
frequency,
|
|
2342
|
+
category,
|
|
2343
|
+
overlap_with_you: isOverlap,
|
|
2344
|
+
profile: profile.data,
|
|
2345
|
+
error: profile.error,
|
|
2346
|
+
});
|
|
2347
|
+
await sleep(1500);
|
|
2348
|
+
}
|
|
2349
|
+
return {
|
|
2350
|
+
competitor: profileUrl,
|
|
2351
|
+
posts_scanned: Math.min(postsToScan, postUrls.length),
|
|
2352
|
+
unique_commenters: commenterFrequency.size,
|
|
2353
|
+
enriched: enriched.length,
|
|
2354
|
+
commenters: enriched,
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
async function influencerVetter(args, ctx) {
|
|
2358
|
+
const profileUrl = args.profile_url;
|
|
2359
|
+
const postsToAnalyze = args.posts_to_analyze ?? 10;
|
|
2360
|
+
const scrollCount = args.scrolls ?? 3;
|
|
2361
|
+
// Visit profile
|
|
2362
|
+
const tab = (await call(ctx, "openTab", { url: profileUrl }));
|
|
2363
|
+
const tabId = tab?.tabId;
|
|
2364
|
+
if (!tabId)
|
|
2365
|
+
throw new Error("Failed to open tab");
|
|
2366
|
+
await sleep(3000);
|
|
2367
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
2368
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
2369
|
+
await sleep(500);
|
|
2370
|
+
}
|
|
2371
|
+
const profileData = (await call(ctx, "extractData", { tabId }));
|
|
2372
|
+
const text = JSON.stringify(profileData).toLowerCase();
|
|
2373
|
+
// Extract metrics
|
|
2374
|
+
const followerMatch = text.match(/(\d[\d,]*)\s*followers/);
|
|
2375
|
+
const followingMatch = text.match(/(\d[\d,]*)\s*following/);
|
|
2376
|
+
const postsMatch = text.match(/(\d[\d,]*)\s*posts/);
|
|
2377
|
+
const followers = followerMatch ? parseInt(followerMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2378
|
+
const following = followingMatch ? parseInt(followingMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2379
|
+
const postCount = postsMatch ? parseInt(postsMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2380
|
+
// Analyze engagement from visible posts
|
|
2381
|
+
const items = Array.isArray(profileData)
|
|
2382
|
+
? profileData
|
|
2383
|
+
: profileData?.items ?? [profileData];
|
|
2384
|
+
let totalEngagement = 0;
|
|
2385
|
+
let genericComments = 0;
|
|
2386
|
+
let substantiveComments = 0;
|
|
2387
|
+
for (const item of items.slice(0, postsToAnalyze)) {
|
|
2388
|
+
const itemText = JSON.stringify(item).toLowerCase();
|
|
2389
|
+
const likeMatch = itemText.match(/(\d[\d,]*)\s*likes?/);
|
|
2390
|
+
const commentMatch = itemText.match(/(\d[\d,]*)\s*comments?/);
|
|
2391
|
+
totalEngagement += likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2392
|
+
totalEngagement += commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2393
|
+
// Simple comment quality heuristic
|
|
2394
|
+
const genericPatterns = /^(nice|great|love|wow|fire|amazing|beautiful|❤|🔥|👏|💯|😍)\s*[!.]?$/;
|
|
2395
|
+
if (genericPatterns.test(itemText.trim()))
|
|
2396
|
+
genericComments++;
|
|
2397
|
+
else if (itemText.length > 20)
|
|
2398
|
+
substantiveComments++;
|
|
2399
|
+
}
|
|
2400
|
+
await closeTab(ctx, tabId);
|
|
2401
|
+
// Calculate scores
|
|
2402
|
+
const engagementRate = followers > 0 ? (totalEngagement / Math.max(items.length, 1)) / followers * 100 : 0;
|
|
2403
|
+
const followRatio = following > 0 ? followers / following : 0;
|
|
2404
|
+
const commentQuality = (genericComments + substantiveComments) > 0
|
|
2405
|
+
? substantiveComments / (genericComments + substantiveComments) * 100
|
|
2406
|
+
: 50;
|
|
2407
|
+
// Build trust score
|
|
2408
|
+
let trustScore = 50;
|
|
2409
|
+
const redFlags = [];
|
|
2410
|
+
const positives = [];
|
|
2411
|
+
// Engagement rate scoring
|
|
2412
|
+
if (engagementRate > 3) {
|
|
2413
|
+
trustScore += 15;
|
|
2414
|
+
positives.push(`Good engagement rate: ${engagementRate.toFixed(2)}%`);
|
|
2415
|
+
}
|
|
2416
|
+
else if (engagementRate > 1) {
|
|
2417
|
+
trustScore += 5;
|
|
2418
|
+
}
|
|
2419
|
+
else if (engagementRate < 0.5 && followers > 10000) {
|
|
2420
|
+
trustScore -= 20;
|
|
2421
|
+
redFlags.push(`Very low engagement (${engagementRate.toFixed(2)}%) for ${followers.toLocaleString()} followers — possible purchased audience`);
|
|
2422
|
+
}
|
|
2423
|
+
// Follow ratio
|
|
2424
|
+
if (followRatio > 5) {
|
|
2425
|
+
trustScore += 10;
|
|
2426
|
+
positives.push("Healthy follower-to-following ratio");
|
|
2427
|
+
}
|
|
2428
|
+
else if (followRatio < 1) {
|
|
2429
|
+
trustScore -= 10;
|
|
2430
|
+
redFlags.push("Following more people than followers — may use follow/unfollow tactics");
|
|
2431
|
+
}
|
|
2432
|
+
// Comment quality
|
|
2433
|
+
if (commentQuality > 60) {
|
|
2434
|
+
trustScore += 10;
|
|
2435
|
+
positives.push("Substantive comment quality");
|
|
2436
|
+
}
|
|
2437
|
+
else if (commentQuality < 30) {
|
|
2438
|
+
trustScore -= 10;
|
|
2439
|
+
redFlags.push("Most engagement appears generic/bot-like");
|
|
2440
|
+
}
|
|
2441
|
+
// Posting consistency
|
|
2442
|
+
if (postCount > 100) {
|
|
2443
|
+
trustScore += 5;
|
|
2444
|
+
positives.push("Consistent posting history");
|
|
2445
|
+
}
|
|
2446
|
+
trustScore = Math.max(0, Math.min(100, trustScore));
|
|
2447
|
+
return {
|
|
2448
|
+
profile_url: profileUrl,
|
|
2449
|
+
platform: detectPlatform(profileUrl),
|
|
2450
|
+
trust_score: trustScore,
|
|
2451
|
+
verdict: trustScore >= 70 ? "likely_authentic" : trustScore >= 40 ? "moderate_risk" : "high_risk",
|
|
2452
|
+
metrics: {
|
|
2453
|
+
followers,
|
|
2454
|
+
following,
|
|
2455
|
+
posts: postCount,
|
|
2456
|
+
engagement_rate: `${engagementRate.toFixed(2)}%`,
|
|
2457
|
+
follow_ratio: followRatio.toFixed(1),
|
|
2458
|
+
comment_quality: `${commentQuality.toFixed(0)}%`,
|
|
2459
|
+
},
|
|
2460
|
+
red_flags: redFlags,
|
|
2461
|
+
positives,
|
|
2462
|
+
posts_analyzed: Math.min(postsToAnalyze, items.length),
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
async function engagementAnalyzer(args, ctx) {
|
|
2466
|
+
const profileUrl = args.profile_url;
|
|
2467
|
+
const postsToAnalyze = args.posts_to_analyze ?? 20;
|
|
2468
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2469
|
+
const tab = (await call(ctx, "openTab", { url: profileUrl }));
|
|
2470
|
+
const tabId = tab?.tabId;
|
|
2471
|
+
if (!tabId)
|
|
2472
|
+
throw new Error("Failed to open tab");
|
|
2473
|
+
await sleep(3000);
|
|
2474
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
2475
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
2476
|
+
await sleep(500);
|
|
2477
|
+
}
|
|
2478
|
+
const profileData = (await call(ctx, "extractData", { tabId }));
|
|
2479
|
+
const items = Array.isArray(profileData)
|
|
2480
|
+
? profileData
|
|
2481
|
+
: profileData?.items ?? [profileData];
|
|
2482
|
+
await closeTab(ctx, tabId);
|
|
2483
|
+
// Analyze posts
|
|
2484
|
+
const postAnalysis = [];
|
|
2485
|
+
let totalLikes = 0;
|
|
2486
|
+
let totalComments = 0;
|
|
2487
|
+
const hashtags = {};
|
|
2488
|
+
for (const item of items.slice(0, postsToAnalyze)) {
|
|
2489
|
+
const text = JSON.stringify(item).toLowerCase();
|
|
2490
|
+
const likeMatch = text.match(/(\d[\d,]*)\s*likes?/);
|
|
2491
|
+
const commentMatch = text.match(/(\d[\d,]*)\s*comments?/);
|
|
2492
|
+
const likes = likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2493
|
+
const comments = commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : 0;
|
|
2494
|
+
totalLikes += likes;
|
|
2495
|
+
totalComments += comments;
|
|
2496
|
+
// Extract hashtags
|
|
2497
|
+
const hashMatches = text.match(/#\w+/g) ?? [];
|
|
2498
|
+
for (const h of hashMatches) {
|
|
2499
|
+
hashtags[h] = (hashtags[h] ?? 0) + 1;
|
|
2500
|
+
}
|
|
2501
|
+
// Detect content type
|
|
2502
|
+
let contentType = "text";
|
|
2503
|
+
if (text.includes("video") || text.includes("reel"))
|
|
2504
|
+
contentType = "video";
|
|
2505
|
+
else if (text.includes("carousel") || text.includes("slides"))
|
|
2506
|
+
contentType = "carousel";
|
|
2507
|
+
else if (text.includes("image") || text.includes("photo"))
|
|
2508
|
+
contentType = "image";
|
|
2509
|
+
postAnalysis.push({ likes, comments, engagement: likes + comments, content_type: contentType });
|
|
2510
|
+
}
|
|
2511
|
+
const avgEngagement = postAnalysis.length > 0
|
|
2512
|
+
? (totalLikes + totalComments) / postAnalysis.length
|
|
2513
|
+
: 0;
|
|
2514
|
+
// Top performing content types
|
|
2515
|
+
const byType = {};
|
|
2516
|
+
for (const p of postAnalysis) {
|
|
2517
|
+
const ct = p.content_type;
|
|
2518
|
+
if (!byType[ct])
|
|
2519
|
+
byType[ct] = { count: 0, avg_engagement: 0 };
|
|
2520
|
+
byType[ct].count++;
|
|
2521
|
+
byType[ct].avg_engagement += p.engagement;
|
|
2522
|
+
}
|
|
2523
|
+
for (const ct of Object.keys(byType)) {
|
|
2524
|
+
byType[ct].avg_engagement = Math.round(byType[ct].avg_engagement / byType[ct].count);
|
|
2525
|
+
}
|
|
2526
|
+
// Top hashtags
|
|
2527
|
+
const topHashtags = Object.entries(hashtags)
|
|
2528
|
+
.sort((a, b) => b[1] - a[1])
|
|
2529
|
+
.slice(0, 10)
|
|
2530
|
+
.map(([tag, count]) => ({ tag, uses: count }));
|
|
2531
|
+
// Recommendations
|
|
2532
|
+
const recommendations = [];
|
|
2533
|
+
const bestType = Object.entries(byType).sort((a, b) => b[1].avg_engagement - a[1].avg_engagement)[0];
|
|
2534
|
+
if (bestType) {
|
|
2535
|
+
recommendations.push(`${bestType[0]} posts average ${bestType[1].avg_engagement} engagement — lean into this format`);
|
|
2536
|
+
}
|
|
2537
|
+
if (topHashtags.length > 0) {
|
|
2538
|
+
recommendations.push(`Top hashtags: ${topHashtags.slice(0, 3).map((h) => h.tag).join(", ")}`);
|
|
2539
|
+
}
|
|
2540
|
+
if (avgEngagement > 0) {
|
|
2541
|
+
recommendations.push(`Average engagement per post: ${Math.round(avgEngagement)}`);
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
profile_url: profileUrl,
|
|
2545
|
+
posts_analyzed: postAnalysis.length,
|
|
2546
|
+
total_likes: totalLikes,
|
|
2547
|
+
total_comments: totalComments,
|
|
2548
|
+
avg_engagement: Math.round(avgEngagement),
|
|
2549
|
+
content_type_breakdown: byType,
|
|
2550
|
+
top_hashtags: topHashtags,
|
|
2551
|
+
recommendations,
|
|
2552
|
+
post_details: postAnalysis,
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
async function mentionTracker(args, ctx) {
|
|
2556
|
+
const url = args.url;
|
|
2557
|
+
const brandTerms = args.brand_terms;
|
|
2558
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2559
|
+
const enrichProfiles = args.enrich_profiles ?? false;
|
|
2560
|
+
const schedule = args.schedule ?? false;
|
|
2561
|
+
const intervalMin = args.check_interval_minutes ?? 60;
|
|
2562
|
+
const { tabId, items } = await extractPosts(ctx, url, scrollCount);
|
|
2563
|
+
const matches = filterByKeywords(items, brandTerms);
|
|
2564
|
+
// Save mentions
|
|
2565
|
+
const mentionsFile = path.join(NEURON_DIR, "mentions.yaml");
|
|
2566
|
+
const store = loadYaml(mentionsFile) ?? { mentions: [] };
|
|
2567
|
+
for (const match of matches) {
|
|
2568
|
+
store.mentions.push({
|
|
2569
|
+
...match,
|
|
2570
|
+
brand_terms_matched: brandTerms.filter((t) => JSON.stringify(match).toLowerCase().includes(t.toLowerCase())),
|
|
2571
|
+
source_url: url,
|
|
2572
|
+
detected_at: new Date().toISOString(),
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
saveYaml(mentionsFile, store);
|
|
2576
|
+
// Enrich if requested
|
|
2577
|
+
let enriched = [];
|
|
2578
|
+
if (enrichProfiles && matches.length > 0) {
|
|
2579
|
+
const authorUrls = new Set();
|
|
2580
|
+
for (const item of matches) {
|
|
2581
|
+
const obj = item;
|
|
2582
|
+
const links = (obj.links ?? obj.url ?? obj.href ?? "");
|
|
2583
|
+
for (const link of (Array.isArray(links) ? links : [links])) {
|
|
2584
|
+
if (typeof link === "string" && link.startsWith("http"))
|
|
2585
|
+
authorUrls.add(link);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
for (const profileUrl of [...authorUrls].slice(0, 10)) {
|
|
2589
|
+
enriched.push(await researchSingleProfile(ctx, profileUrl, 2));
|
|
2590
|
+
await sleep(1500);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
await closeTab(ctx, tabId);
|
|
2594
|
+
// Schedule if requested
|
|
2595
|
+
let monitorId = null;
|
|
2596
|
+
if (schedule) {
|
|
2597
|
+
const monitorsFile = path.join(NEURON_DIR, "monitors.yaml");
|
|
2598
|
+
const monitorStore = loadYaml(monitorsFile) ?? { monitors: [] };
|
|
2599
|
+
monitorId = `mt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2600
|
+
monitorStore.monitors.push({
|
|
2601
|
+
id: monitorId,
|
|
2602
|
+
url,
|
|
2603
|
+
name: `Mention Tracker: ${brandTerms.slice(0, 3).join(", ")}`,
|
|
2604
|
+
check_interval_minutes: intervalMin,
|
|
2605
|
+
condition: { type: "new_items", metadata: { brand_terms: brandTerms, source: "mention_tracker" } },
|
|
2606
|
+
enabled: true,
|
|
2607
|
+
created_at: new Date().toISOString(),
|
|
2608
|
+
alert_count: 0,
|
|
2609
|
+
});
|
|
2610
|
+
saveYaml(monitorsFile, monitorStore);
|
|
2611
|
+
}
|
|
2612
|
+
return {
|
|
2613
|
+
url,
|
|
2614
|
+
brand_terms: brandTerms,
|
|
2615
|
+
total_scanned: items.length,
|
|
2616
|
+
mentions_found: matches.length,
|
|
2617
|
+
mentions: matches.slice(0, 30),
|
|
2618
|
+
enriched_profiles: enriched.length > 0 ? enriched : undefined,
|
|
2619
|
+
monitor: monitorId ? { id: monitorId, interval: intervalMin } : undefined,
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
async function postAnalyzer(args, ctx) {
|
|
2623
|
+
const url = args.url;
|
|
2624
|
+
const batch = args.batch ?? false;
|
|
2625
|
+
const postsCount = args.posts_count ?? 20;
|
|
2626
|
+
const scrollCount = args.scrolls ?? 3;
|
|
2627
|
+
const { tabId, items } = await extractPosts(ctx, url, batch ? scrollCount + 2 : scrollCount);
|
|
2628
|
+
const analyzed = items.slice(0, batch ? postsCount : 1).map((item) => {
|
|
2629
|
+
const text = JSON.stringify(item).toLowerCase();
|
|
2630
|
+
const obj = item;
|
|
2631
|
+
const likeMatch = text.match(/(\d[\d,]*)\s*likes?/);
|
|
2632
|
+
const commentMatch = text.match(/(\d[\d,]*)\s*comments?/);
|
|
2633
|
+
const shareMatch = text.match(/(\d[\d,]*)\s*(shares?|reposts?)/);
|
|
2634
|
+
const viewMatch = text.match(/(\d[\d,]*)\s*views?/);
|
|
2635
|
+
const hashMatches = text.match(/#\w+/g) ?? [];
|
|
2636
|
+
const mentionMatches = text.match(/@\w+/g) ?? [];
|
|
2637
|
+
let contentType = "text";
|
|
2638
|
+
if (text.includes("video") || text.includes("reel"))
|
|
2639
|
+
contentType = "video";
|
|
2640
|
+
else if (text.includes("carousel") || text.includes("slides"))
|
|
2641
|
+
contentType = "carousel";
|
|
2642
|
+
else if (text.includes("image") || text.includes("photo"))
|
|
2643
|
+
contentType = "image";
|
|
2644
|
+
return {
|
|
2645
|
+
...obj,
|
|
2646
|
+
metrics: {
|
|
2647
|
+
likes: likeMatch ? parseInt(likeMatch[1].replace(/,/g, ""), 10) : null,
|
|
2648
|
+
comments: commentMatch ? parseInt(commentMatch[1].replace(/,/g, ""), 10) : null,
|
|
2649
|
+
shares: shareMatch ? parseInt(shareMatch[1].replace(/,/g, ""), 10) : null,
|
|
2650
|
+
views: viewMatch ? parseInt(viewMatch[1].replace(/,/g, ""), 10) : null,
|
|
2651
|
+
},
|
|
2652
|
+
content_type: contentType,
|
|
2653
|
+
hashtags: hashMatches,
|
|
2654
|
+
mentions: mentionMatches,
|
|
2655
|
+
};
|
|
2656
|
+
});
|
|
2657
|
+
await closeTab(ctx, tabId);
|
|
2658
|
+
return {
|
|
2659
|
+
url,
|
|
2660
|
+
platform: detectPlatform(url),
|
|
2661
|
+
batch,
|
|
2662
|
+
posts_analyzed: analyzed.length,
|
|
2663
|
+
posts: analyzed,
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
async function contentPipeline(args, ctx) {
|
|
2667
|
+
const sourceUrl = args.source_url;
|
|
2668
|
+
const targetPlatform = args.target_platform;
|
|
2669
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2670
|
+
// Extract source content
|
|
2671
|
+
const { tabId, items } = await extractPosts(ctx, sourceUrl, scrollCount);
|
|
2672
|
+
// Also get the page's text content
|
|
2673
|
+
let pageText = "";
|
|
2674
|
+
try {
|
|
2675
|
+
pageText = String(await call(ctx, "evaluateJS", {
|
|
2676
|
+
tabId,
|
|
2677
|
+
expression: `
|
|
2678
|
+
const article = document.querySelector('article') || document.querySelector('[role="main"]') || document.body;
|
|
2679
|
+
article.innerText.substring(0, 5000);
|
|
2680
|
+
`,
|
|
2681
|
+
}));
|
|
2682
|
+
}
|
|
2683
|
+
catch {
|
|
2684
|
+
pageText = JSON.stringify(items).substring(0, 5000);
|
|
2685
|
+
}
|
|
2686
|
+
await closeTab(ctx, tabId);
|
|
2687
|
+
// Platform-specific formatting hints
|
|
2688
|
+
const formatGuide = {
|
|
2689
|
+
linkedin: {
|
|
2690
|
+
max_chars: 3000,
|
|
2691
|
+
format: "line breaks between sentences, emoji sparingly, 3-5 hashtags at end",
|
|
2692
|
+
hook_style: "Bold first line, line break, story or stat",
|
|
2693
|
+
cta: "Comment below, follow for more, share if you agree",
|
|
2694
|
+
},
|
|
2695
|
+
x: {
|
|
2696
|
+
max_chars: 280,
|
|
2697
|
+
format: "Thread if long, numbered tweets, no hashtag spam",
|
|
2698
|
+
hook_style: "Hot take, contrarian, or surprising stat",
|
|
2699
|
+
cta: "RT if you agree, follow for more",
|
|
2700
|
+
},
|
|
2701
|
+
instagram: {
|
|
2702
|
+
max_chars: 2200,
|
|
2703
|
+
format: "Caption with line breaks, 20-30 hashtags in first comment",
|
|
2704
|
+
hook_style: "Stop-scroll first sentence, emoji hooks",
|
|
2705
|
+
cta: "Save this, share to stories, link in bio",
|
|
2706
|
+
},
|
|
2707
|
+
tiktok: {
|
|
2708
|
+
max_chars: 300,
|
|
2709
|
+
format: "Script format: hook (3s), body (20-40s), CTA (3s)",
|
|
2710
|
+
hook_style: "POV:, What if, Story time, Controversial opener",
|
|
2711
|
+
cta: "Follow for part 2, comment your experience",
|
|
2712
|
+
},
|
|
2713
|
+
newsletter: {
|
|
2714
|
+
max_chars: 10000,
|
|
2715
|
+
format: "Subject line, preview text, sections with headers, CTA button",
|
|
2716
|
+
hook_style: "Subject: curiosity gap or direct benefit",
|
|
2717
|
+
cta: "Reply to this email, share with a friend",
|
|
2718
|
+
},
|
|
2719
|
+
blog: {
|
|
2720
|
+
max_chars: 20000,
|
|
2721
|
+
format: "H1, intro, H2 sections, bullet points, conclusion, meta description",
|
|
2722
|
+
hook_style: "SEO-aware title, engaging first paragraph",
|
|
2723
|
+
cta: "Subscribe, read related posts",
|
|
2724
|
+
},
|
|
2725
|
+
};
|
|
2726
|
+
return {
|
|
2727
|
+
source_url: sourceUrl,
|
|
2728
|
+
source_platform: detectPlatform(sourceUrl),
|
|
2729
|
+
target_platform: targetPlatform,
|
|
2730
|
+
source_content: pageText.substring(0, 3000),
|
|
2731
|
+
source_data: items.slice(0, 5),
|
|
2732
|
+
format_guide: formatGuide[targetPlatform] ?? formatGuide.blog,
|
|
2733
|
+
hint: "Use the source_content and format_guide to restructure the content for the target platform. " +
|
|
2734
|
+
"Write the hook, body, and CTA following the platform's conventions.",
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
async function groupLinkScanner(args, ctx) {
|
|
2738
|
+
const url = args.url;
|
|
2739
|
+
const scrollCount = args.scrolls ?? 5;
|
|
2740
|
+
const validateLinks = args.validate_links ?? false;
|
|
2741
|
+
const enrichProfiles = args.enrich_profiles ?? false;
|
|
2742
|
+
const { tabId, items } = await extractPosts(ctx, url, scrollCount);
|
|
2743
|
+
// Also scan full page text for group links
|
|
2744
|
+
let fullText = "";
|
|
2745
|
+
try {
|
|
2746
|
+
fullText = String(await call(ctx, "evaluateJS", {
|
|
2747
|
+
tabId,
|
|
2748
|
+
expression: "document.body.innerText.substring(0, 50000)",
|
|
2749
|
+
}));
|
|
2750
|
+
}
|
|
2751
|
+
catch { /* ok */ }
|
|
2752
|
+
await closeTab(ctx, tabId);
|
|
2753
|
+
// Regex patterns for group links
|
|
2754
|
+
const patterns = [
|
|
2755
|
+
/https?:\/\/wa\.me\/[^\s"',<>]+/gi,
|
|
2756
|
+
/https?:\/\/chat\.whatsapp\.com\/[^\s"',<>]+/gi,
|
|
2757
|
+
/https?:\/\/t\.me\/[^\s"',<>]+/gi,
|
|
2758
|
+
/https?:\/\/telegram\.me\/[^\s"',<>]+/gi,
|
|
2759
|
+
];
|
|
2760
|
+
const allText = fullText + " " + JSON.stringify(items);
|
|
2761
|
+
const groupLinks = [];
|
|
2762
|
+
const seen = new Set();
|
|
2763
|
+
for (const pattern of patterns) {
|
|
2764
|
+
const matches = allText.match(pattern) ?? [];
|
|
2765
|
+
for (const match of matches) {
|
|
2766
|
+
const cleaned = match.replace(/[),.'";]+$/, "");
|
|
2767
|
+
if (seen.has(cleaned))
|
|
2768
|
+
continue;
|
|
2769
|
+
seen.add(cleaned);
|
|
2770
|
+
const type = cleaned.includes("whatsapp") || cleaned.includes("wa.me")
|
|
2771
|
+
? "whatsapp" : "telegram";
|
|
2772
|
+
// Extract surrounding context
|
|
2773
|
+
const idx = allText.indexOf(cleaned);
|
|
2774
|
+
const context = allText.substring(Math.max(0, idx - 100), idx + cleaned.length + 100).trim();
|
|
2775
|
+
groupLinks.push({ url: cleaned, type, context: context.substring(0, 200) });
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
// Save to file
|
|
2779
|
+
const groupLinksFile = path.join(NEURON_DIR, "group-links.yaml");
|
|
2780
|
+
const store = loadYaml(groupLinksFile) ?? { links: [] };
|
|
2781
|
+
for (const link of groupLinks) {
|
|
2782
|
+
store.links.push({
|
|
2783
|
+
...link,
|
|
2784
|
+
source_url: url,
|
|
2785
|
+
found_at: new Date().toISOString(),
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
saveYaml(groupLinksFile, store);
|
|
2789
|
+
return {
|
|
2790
|
+
source_url: url,
|
|
2791
|
+
total_links_found: groupLinks.length,
|
|
2792
|
+
whatsapp: groupLinks.filter((l) => l.type === "whatsapp").length,
|
|
2793
|
+
telegram: groupLinks.filter((l) => l.type === "telegram").length,
|
|
2794
|
+
links: groupLinks,
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
async function bioHarvester(args, ctx) {
|
|
2798
|
+
const urls = args.urls.slice(0, 20);
|
|
2799
|
+
const followLinktree = args.follow_linktree ?? true;
|
|
2800
|
+
const delayMs = args.delay_ms ?? 2000;
|
|
2801
|
+
const results = [];
|
|
2802
|
+
for (const profileUrl of urls) {
|
|
2803
|
+
try {
|
|
2804
|
+
const tab = (await call(ctx, "openTab", { url: profileUrl }));
|
|
2805
|
+
const tabId = tab?.tabId;
|
|
2806
|
+
if (!tabId) {
|
|
2807
|
+
results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: [], error: "Failed to open tab" });
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
await sleep(2500);
|
|
2811
|
+
// Extract all links from the page
|
|
2812
|
+
const linkData = await call(ctx, "evaluateJS", {
|
|
2813
|
+
tabId,
|
|
2814
|
+
expression: `
|
|
2815
|
+
(function() {
|
|
2816
|
+
const links = [];
|
|
2817
|
+
// Bio section links
|
|
2818
|
+
document.querySelectorAll('a[href]').forEach(a => {
|
|
2819
|
+
const href = a.href;
|
|
2820
|
+
if (href && !href.startsWith('javascript') && !href.includes(window.location.hostname + '/')) {
|
|
2821
|
+
links.push(href);
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
// Check for email patterns in bio text
|
|
2825
|
+
const bioText = document.body.innerText;
|
|
2826
|
+
const emails = bioText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g) || [];
|
|
2827
|
+
const phones = bioText.match(/(\\+?\\d{1,3}[-.\\s]?)?\\(?\\d{1,4}\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}/g) || [];
|
|
2828
|
+
return JSON.stringify({ links: [...new Set(links)], emails, phones });
|
|
2829
|
+
})()
|
|
2830
|
+
`,
|
|
2831
|
+
});
|
|
2832
|
+
const parsed = JSON.parse(String(linkData ?? '{"links":[],"emails":[],"phones":[]}'));
|
|
2833
|
+
// Categorize links
|
|
2834
|
+
const categorized = [];
|
|
2835
|
+
for (const link of parsed.links) {
|
|
2836
|
+
const lower = link.toLowerCase();
|
|
2837
|
+
let category = "website";
|
|
2838
|
+
if (lower.includes("wa.me") || lower.includes("whatsapp"))
|
|
2839
|
+
category = "whatsapp";
|
|
2840
|
+
else if (lower.includes("t.me") || lower.includes("telegram"))
|
|
2841
|
+
category = "telegram";
|
|
2842
|
+
else if (lower.includes("linktr.ee") || lower.includes("beacons.ai") || lower.includes("stan.store") || lower.includes("linkin.bio"))
|
|
2843
|
+
category = "link_in_bio";
|
|
2844
|
+
else if (lower.includes("calendly") || lower.includes("cal.com"))
|
|
2845
|
+
category = "booking";
|
|
2846
|
+
else if (lower.includes("youtube") || lower.includes("youtu.be"))
|
|
2847
|
+
category = "youtube";
|
|
2848
|
+
else if (lower.includes("tiktok"))
|
|
2849
|
+
category = "tiktok";
|
|
2850
|
+
else if (lower.includes("twitter") || lower.includes("x.com"))
|
|
2851
|
+
category = "x";
|
|
2852
|
+
else if (lower.includes("linkedin"))
|
|
2853
|
+
category = "linkedin";
|
|
2854
|
+
else if (lower.includes("facebook") || lower.includes("fb.com"))
|
|
2855
|
+
category = "facebook";
|
|
2856
|
+
else if (lower.includes("github"))
|
|
2857
|
+
category = "github";
|
|
2858
|
+
categorized.push({ url: link, category });
|
|
2859
|
+
}
|
|
2860
|
+
for (const email of parsed.emails) {
|
|
2861
|
+
categorized.push({ url: `mailto:${email}`, category: "email" });
|
|
2862
|
+
}
|
|
2863
|
+
for (const phone of parsed.phones) {
|
|
2864
|
+
categorized.push({ url: `tel:${phone}`, category: "phone" });
|
|
2865
|
+
}
|
|
2866
|
+
// Follow link-in-bio pages
|
|
2867
|
+
if (followLinktree) {
|
|
2868
|
+
const linktreeLinks = categorized.filter((l) => l.category === "link_in_bio");
|
|
2869
|
+
for (const ltLink of linktreeLinks.slice(0, 1)) {
|
|
2870
|
+
try {
|
|
2871
|
+
const ltTab = (await call(ctx, "openTab", { url: ltLink.url }));
|
|
2872
|
+
if (ltTab?.tabId) {
|
|
2873
|
+
await sleep(2000);
|
|
2874
|
+
const ltData = await call(ctx, "evaluateJS", {
|
|
2875
|
+
tabId: ltTab.tabId,
|
|
2876
|
+
expression: `
|
|
2877
|
+
JSON.stringify([...document.querySelectorAll('a[href]')].map(a => a.href).filter(h =>
|
|
2878
|
+
h && !h.includes(window.location.hostname) && !h.startsWith('javascript')
|
|
2879
|
+
))
|
|
2880
|
+
`,
|
|
2881
|
+
});
|
|
2882
|
+
const ltLinks = JSON.parse(String(ltData ?? "[]"));
|
|
2883
|
+
for (const ll of ltLinks) {
|
|
2884
|
+
categorized.push({ url: ll, category: "from_linktree" });
|
|
2885
|
+
}
|
|
2886
|
+
await closeTab(ctx, ltTab.tabId);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
catch { /* ok */ }
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
await closeTab(ctx, tabId);
|
|
2893
|
+
results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: categorized, error: null });
|
|
2894
|
+
}
|
|
2895
|
+
catch (err) {
|
|
2896
|
+
results.push({ profile_url: profileUrl, platform: detectPlatform(profileUrl), links: [], error: err.message });
|
|
2897
|
+
}
|
|
2898
|
+
if (delayMs > 0)
|
|
2899
|
+
await sleep(delayMs);
|
|
2900
|
+
}
|
|
2901
|
+
return {
|
|
2902
|
+
total_profiles: results.length,
|
|
2903
|
+
total_links: results.reduce((sum, r) => sum + r.links.length, 0),
|
|
2904
|
+
profiles: results,
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
async function crossPlatformFinder(args, ctx) {
|
|
2908
|
+
const handle = args.handle;
|
|
2909
|
+
const sourcePlatform = args.source_platform;
|
|
2910
|
+
const searchPlatforms = args.search_platforms ??
|
|
2911
|
+
["ig", "x", "li", "tk", "fb"].filter((p) => p !== sourcePlatform);
|
|
2912
|
+
const platformSearchUrls = {
|
|
2913
|
+
ig: `https://www.instagram.com/${handle}/`,
|
|
2914
|
+
x: `https://x.com/${handle}`,
|
|
2915
|
+
li: `https://www.linkedin.com/in/${handle}/`,
|
|
2916
|
+
tk: `https://www.tiktok.com/@${handle}`,
|
|
2917
|
+
fb: `https://www.facebook.com/${handle}`,
|
|
2918
|
+
};
|
|
2919
|
+
const results = [];
|
|
2920
|
+
for (const platform of searchPlatforms) {
|
|
2921
|
+
const searchUrl = platformSearchUrls[platform];
|
|
2922
|
+
if (!searchUrl)
|
|
2923
|
+
continue;
|
|
2924
|
+
try {
|
|
2925
|
+
const profile = await researchSingleProfile(ctx, searchUrl, 1);
|
|
2926
|
+
const hasContent = profile.data != null && !profile.error;
|
|
2927
|
+
const text = JSON.stringify(profile.data ?? "").toLowerCase();
|
|
2928
|
+
// Check if the page is a valid profile (not a 404/error page)
|
|
2929
|
+
const is404 = text.includes("sorry, this page isn") ||
|
|
2930
|
+
text.includes("this account doesn") ||
|
|
2931
|
+
text.includes("this page isn't available") ||
|
|
2932
|
+
text.includes("user not found") ||
|
|
2933
|
+
text.length < 100;
|
|
2934
|
+
results.push({
|
|
2935
|
+
platform,
|
|
2936
|
+
url: searchUrl,
|
|
2937
|
+
found: hasContent && !is404,
|
|
2938
|
+
confidence: hasContent && !is404 ? "handle_match" : "not_found",
|
|
2939
|
+
data: hasContent && !is404 ? profile.data : null,
|
|
2940
|
+
error: profile.error,
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
catch (err) {
|
|
2944
|
+
results.push({
|
|
2945
|
+
platform,
|
|
2946
|
+
url: searchUrl,
|
|
2947
|
+
found: false,
|
|
2948
|
+
confidence: "error",
|
|
2949
|
+
data: null,
|
|
2950
|
+
error: err.message,
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
await sleep(2000);
|
|
2954
|
+
}
|
|
2955
|
+
return {
|
|
2956
|
+
handle,
|
|
2957
|
+
source_platform: sourcePlatform,
|
|
2958
|
+
platforms_searched: searchPlatforms.length,
|
|
2959
|
+
found_on: results.filter((r) => r.found).length,
|
|
2960
|
+
results,
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
async function pageWeight(args, ctx) {
|
|
2964
|
+
const url = args.url;
|
|
2965
|
+
const budget = (args.budget ?? {});
|
|
2966
|
+
const maxJsKb = budget.max_js_kb ?? 300;
|
|
2967
|
+
const maxCssKb = budget.max_css_kb ?? 100;
|
|
2968
|
+
const maxImagesKb = budget.max_images_kb ?? 500;
|
|
2969
|
+
const maxTotalKb = budget.max_total_kb ?? 1500;
|
|
2970
|
+
const maxSingleKb = budget.max_single_asset_kb ?? 200;
|
|
2971
|
+
// Open page and let it fully load
|
|
2972
|
+
const tab = (await call(ctx, "openTab", { url }));
|
|
2973
|
+
const tabId = tab?.tabId;
|
|
2974
|
+
if (!tabId)
|
|
2975
|
+
throw new Error("Failed to open tab");
|
|
2976
|
+
await sleep(4000);
|
|
2977
|
+
// Scroll to trigger lazy loads
|
|
2978
|
+
for (let i = 0; i < 3; i++) {
|
|
2979
|
+
await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
|
|
2980
|
+
await sleep(500);
|
|
2981
|
+
}
|
|
2982
|
+
// Get waterfall data for resource sizes and timing
|
|
2983
|
+
const [waterfallData, requests] = await Promise.allSettled([
|
|
2984
|
+
call(ctx, "waterfall", { tabId }),
|
|
2985
|
+
call(ctx, "getRecentRequests", { tabId, limit: 300 }),
|
|
2986
|
+
]);
|
|
2987
|
+
await closeTab(ctx, tabId);
|
|
2988
|
+
// Categorize resources
|
|
2989
|
+
const categories = {
|
|
2990
|
+
javascript: { count: 0, total_kb: 0, assets: [] },
|
|
2991
|
+
css: { count: 0, total_kb: 0, assets: [] },
|
|
2992
|
+
images: { count: 0, total_kb: 0, assets: [] },
|
|
2993
|
+
fonts: { count: 0, total_kb: 0, assets: [] },
|
|
2994
|
+
video: { count: 0, total_kb: 0, assets: [] },
|
|
2995
|
+
other: { count: 0, total_kb: 0, assets: [] },
|
|
2996
|
+
};
|
|
2997
|
+
const requestList = requests.status === "fulfilled"
|
|
2998
|
+
? (requests.value?.requests ?? [])
|
|
2999
|
+
: [];
|
|
3000
|
+
let totalKb = 0;
|
|
3001
|
+
for (const req of requestList) {
|
|
3002
|
+
const r = req;
|
|
3003
|
+
const reqUrl = String(r.url ?? "");
|
|
3004
|
+
const size = (r.responseSize ?? r.size ?? r.contentLength ?? 0) / 1024;
|
|
3005
|
+
const lower = reqUrl.toLowerCase();
|
|
3006
|
+
let cat = "other";
|
|
3007
|
+
if (lower.endsWith(".js") || lower.includes("javascript") || (r.contentType ?? "").includes("javascript"))
|
|
3008
|
+
cat = "javascript";
|
|
3009
|
+
else if (lower.endsWith(".css") || (r.contentType ?? "").includes("css"))
|
|
3010
|
+
cat = "css";
|
|
3011
|
+
else if (/\.(png|jpg|jpeg|gif|webp|avif|svg|ico)/.test(lower) || (r.contentType ?? "").includes("image"))
|
|
3012
|
+
cat = "images";
|
|
3013
|
+
else if (/\.(woff2?|ttf|otf|eot)/.test(lower) || (r.contentType ?? "").includes("font"))
|
|
3014
|
+
cat = "fonts";
|
|
3015
|
+
else if (/\.(mp4|webm|m3u8)/.test(lower) || (r.contentType ?? "").includes("video"))
|
|
3016
|
+
cat = "video";
|
|
3017
|
+
const sizeKb = Math.round(size * 10) / 10;
|
|
3018
|
+
categories[cat].count++;
|
|
3019
|
+
categories[cat].total_kb += sizeKb;
|
|
3020
|
+
categories[cat].assets.push({ url: reqUrl.substring(0, 120), size_kb: sizeKb });
|
|
3021
|
+
totalKb += sizeKb;
|
|
3022
|
+
}
|
|
3023
|
+
// Build recommendations
|
|
3024
|
+
const recommendations = [];
|
|
3025
|
+
const violations = [];
|
|
3026
|
+
if (categories.javascript.total_kb > maxJsKb) {
|
|
3027
|
+
violations.push({ rule: "max_js_kb", actual: Math.round(categories.javascript.total_kb), limit: maxJsKb });
|
|
3028
|
+
recommendations.push({ priority: "high", category: "javascript", message: `JS total ${Math.round(categories.javascript.total_kb)}KB exceeds budget of ${maxJsKb}KB — tree-shake, code-split, or defer non-critical scripts` });
|
|
3029
|
+
}
|
|
3030
|
+
if (categories.css.total_kb > maxCssKb) {
|
|
3031
|
+
violations.push({ rule: "max_css_kb", actual: Math.round(categories.css.total_kb), limit: maxCssKb });
|
|
3032
|
+
recommendations.push({ priority: "medium", category: "css", message: `CSS total ${Math.round(categories.css.total_kb)}KB exceeds budget — purge unused rules, inline critical CSS` });
|
|
3033
|
+
}
|
|
3034
|
+
if (categories.images.total_kb > maxImagesKb) {
|
|
3035
|
+
violations.push({ rule: "max_images_kb", actual: Math.round(categories.images.total_kb), limit: maxImagesKb });
|
|
3036
|
+
recommendations.push({ priority: "high", category: "images", message: `Images total ${Math.round(categories.images.total_kb)}KB exceeds budget — compress, convert to WebP/AVIF, lazy-load below fold` });
|
|
3037
|
+
}
|
|
3038
|
+
if (totalKb > maxTotalKb) {
|
|
3039
|
+
violations.push({ rule: "max_total_kb", actual: Math.round(totalKb), limit: maxTotalKb });
|
|
3040
|
+
}
|
|
3041
|
+
// Flag oversized individual assets
|
|
3042
|
+
for (const [cat, data] of Object.entries(categories)) {
|
|
3043
|
+
for (const asset of data.assets) {
|
|
3044
|
+
if (asset.size_kb > maxSingleKb) {
|
|
3045
|
+
recommendations.push({ priority: "high", category: cat, message: `${asset.url} is ${asset.size_kb}KB — exceeds ${maxSingleKb}KB single asset limit` });
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
// Sort each category's assets by size descending
|
|
3050
|
+
for (const cat of Object.values(categories)) {
|
|
3051
|
+
cat.assets.sort((a, b) => b.size_kb - a.size_kb);
|
|
3052
|
+
cat.assets = cat.assets.slice(0, 5); // keep top 5 per category
|
|
3053
|
+
cat.total_kb = Math.round(cat.total_kb);
|
|
3054
|
+
}
|
|
3055
|
+
return {
|
|
3056
|
+
url,
|
|
3057
|
+
total_kb: Math.round(totalKb),
|
|
3058
|
+
total_requests: requestList.length,
|
|
3059
|
+
categories,
|
|
3060
|
+
budget_violations: violations,
|
|
3061
|
+
recommendations: recommendations.sort((a, b) => a.priority === "high" ? -1 : 1),
|
|
3062
|
+
waterfall: waterfallData.status === "fulfilled" ? waterfallData.value : undefined,
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
//# sourceMappingURL=business-tools.js.map
|