synabun 2026.3.307 → 2026.3.310
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/CLAUDE.md +150 -0
- package/hooks/claude-code/post-plan.mjs +1 -1
- package/hooks/claude-code/session-start.mjs +2 -0
- package/mcp-server/dist/config.d.ts +13 -0
- package/mcp-server/dist/config.d.ts.map +1 -0
- package/mcp-server/dist/config.js +59 -0
- package/mcp-server/dist/config.js.map +1 -0
- package/mcp-server/dist/http.d.ts +13 -0
- package/mcp-server/dist/http.d.ts.map +1 -0
- package/mcp-server/dist/http.js +86 -0
- package/mcp-server/dist/http.js.map +1 -0
- package/mcp-server/dist/index.d.ts +10 -0
- package/mcp-server/dist/index.d.ts.map +1 -0
- package/mcp-server/dist/index.js +189 -0
- package/mcp-server/dist/index.js.map +1 -0
- package/mcp-server/dist/preload-http.d.ts +3 -0
- package/mcp-server/dist/preload-http.d.ts.map +1 -0
- package/mcp-server/dist/preload-http.js +26 -0
- package/mcp-server/dist/preload-http.js.map +1 -0
- package/mcp-server/dist/preload.d.ts +3 -0
- package/mcp-server/dist/preload.d.ts.map +1 -0
- package/mcp-server/dist/preload.js +30 -0
- package/mcp-server/dist/preload.js.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
- package/mcp-server/dist/services/categories.d.ts +61 -0
- package/mcp-server/dist/services/categories.d.ts.map +1 -0
- package/mcp-server/dist/services/categories.js +356 -0
- package/mcp-server/dist/services/categories.js.map +1 -0
- package/mcp-server/dist/services/discord.d.ts +112 -0
- package/mcp-server/dist/services/discord.d.ts.map +1 -0
- package/mcp-server/dist/services/discord.js +477 -0
- package/mcp-server/dist/services/discord.js.map +1 -0
- package/mcp-server/dist/services/file-checksums.d.ts +14 -0
- package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
- package/mcp-server/dist/services/file-checksums.js +34 -0
- package/mcp-server/dist/services/file-checksums.js.map +1 -0
- package/mcp-server/dist/services/local-embeddings.d.ts +23 -0
- package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
- package/mcp-server/dist/services/local-embeddings.js +71 -0
- package/mcp-server/dist/services/local-embeddings.js.map +1 -0
- package/mcp-server/dist/services/neural-interface.d.ts +92 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
- package/mcp-server/dist/services/neural-interface.js +238 -0
- package/mcp-server/dist/services/neural-interface.js.map +1 -0
- package/mcp-server/dist/services/sqlite.d.ts +85 -0
- package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
- package/mcp-server/dist/services/sqlite.js +660 -0
- package/mcp-server/dist/services/sqlite.js.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts +54 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.js +97 -0
- package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
- package/mcp-server/dist/tools/browser-interact.d.ts +138 -0
- package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-interact.js +216 -0
- package/mcp-server/dist/tools/browser-interact.js.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts +52 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.js +61 -0
- package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
- package/mcp-server/dist/tools/browser-observe.d.ts +298 -0
- package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-observe.js +1154 -0
- package/mcp-server/dist/tools/browser-observe.js.map +1 -0
- package/mcp-server/dist/tools/browser.d.ts +7 -0
- package/mcp-server/dist/tools/browser.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser.js +55 -0
- package/mcp-server/dist/tools/browser.js.map +1 -0
- package/mcp-server/dist/tools/card-tools.d.ts +82 -0
- package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/card-tools.js +137 -0
- package/mcp-server/dist/tools/card-tools.js.map +1 -0
- package/mcp-server/dist/tools/card.d.ts +8 -0
- package/mcp-server/dist/tools/card.d.ts.map +1 -0
- package/mcp-server/dist/tools/card.js +14 -0
- package/mcp-server/dist/tools/card.js.map +1 -0
- package/mcp-server/dist/tools/category.d.ts +32 -0
- package/mcp-server/dist/tools/category.d.ts.map +1 -0
- package/mcp-server/dist/tools/category.js +290 -0
- package/mcp-server/dist/tools/category.js.map +1 -0
- package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
- package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-channel.js +236 -0
- package/mcp-server/dist/tools/discord-channel.js.map +1 -0
- package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
- package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-guild.js +138 -0
- package/mcp-server/dist/tools/discord-guild.js.map +1 -0
- package/mcp-server/dist/tools/discord-member.d.ts +26 -0
- package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-member.js +122 -0
- package/mcp-server/dist/tools/discord-member.js.map +1 -0
- package/mcp-server/dist/tools/discord-message.d.ts +36 -0
- package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-message.js +184 -0
- package/mcp-server/dist/tools/discord-message.js.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.js +179 -0
- package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
- package/mcp-server/dist/tools/discord-role.d.ts +34 -0
- package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-role.js +178 -0
- package/mcp-server/dist/tools/discord-role.js.map +1 -0
- package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
- package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-thread.js +88 -0
- package/mcp-server/dist/tools/discord-thread.js.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.js +154 -0
- package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
- package/mcp-server/dist/tools/discord.d.ts +7 -0
- package/mcp-server/dist/tools/discord.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord.js +31 -0
- package/mcp-server/dist/tools/discord.js.map +1 -0
- package/mcp-server/dist/tools/forget.d.ts +14 -0
- package/mcp-server/dist/tools/forget.d.ts.map +1 -0
- package/mcp-server/dist/tools/forget.js +26 -0
- package/mcp-server/dist/tools/forget.js.map +1 -0
- package/mcp-server/dist/tools/git-tools.d.ts +24 -0
- package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/git-tools.js +107 -0
- package/mcp-server/dist/tools/git-tools.js.map +1 -0
- package/mcp-server/dist/tools/git.d.ts +7 -0
- package/mcp-server/dist/tools/git.d.ts.map +1 -0
- package/mcp-server/dist/tools/git.js +9 -0
- package/mcp-server/dist/tools/git.js.map +1 -0
- package/mcp-server/dist/tools/image-tools.d.ts +24 -0
- package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/image-tools.js +80 -0
- package/mcp-server/dist/tools/image-tools.js.map +1 -0
- package/mcp-server/dist/tools/image.d.ts +10 -0
- package/mcp-server/dist/tools/image.d.ts.map +1 -0
- package/mcp-server/dist/tools/image.js +12 -0
- package/mcp-server/dist/tools/image.js.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +77 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js +223 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
- package/mcp-server/dist/tools/leonardo.d.ts +13 -0
- package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo.js +19 -0
- package/mcp-server/dist/tools/leonardo.js.map +1 -0
- package/mcp-server/dist/tools/loop.d.ts +30 -0
- package/mcp-server/dist/tools/loop.d.ts.map +1 -0
- package/mcp-server/dist/tools/loop.js +302 -0
- package/mcp-server/dist/tools/loop.js.map +1 -0
- package/mcp-server/dist/tools/memories.d.ts +26 -0
- package/mcp-server/dist/tools/memories.d.ts.map +1 -0
- package/mcp-server/dist/tools/memories.js +96 -0
- package/mcp-server/dist/tools/memories.js.map +1 -0
- package/mcp-server/dist/tools/recall.d.ts +41 -0
- package/mcp-server/dist/tools/recall.d.ts.map +1 -0
- package/mcp-server/dist/tools/recall.js +275 -0
- package/mcp-server/dist/tools/recall.js.map +1 -0
- package/mcp-server/dist/tools/reflect.d.ts +44 -0
- package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
- package/mcp-server/dist/tools/reflect.js +118 -0
- package/mcp-server/dist/tools/reflect.js.map +1 -0
- package/mcp-server/dist/tools/remember.d.ts +38 -0
- package/mcp-server/dist/tools/remember.d.ts.map +1 -0
- package/mcp-server/dist/tools/remember.js +88 -0
- package/mcp-server/dist/tools/remember.js.map +1 -0
- package/mcp-server/dist/tools/response.d.ts +23 -0
- package/mcp-server/dist/tools/response.d.ts.map +1 -0
- package/mcp-server/dist/tools/response.js +24 -0
- package/mcp-server/dist/tools/response.js.map +1 -0
- package/mcp-server/dist/tools/restore.d.ts +14 -0
- package/mcp-server/dist/tools/restore.d.ts.map +1 -0
- package/mcp-server/dist/tools/restore.js +23 -0
- package/mcp-server/dist/tools/restore.js.map +1 -0
- package/mcp-server/dist/tools/sync.d.ts +18 -0
- package/mcp-server/dist/tools/sync.d.ts.map +1 -0
- package/mcp-server/dist/tools/sync.js +93 -0
- package/mcp-server/dist/tools/sync.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
- package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
- package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe.js +9 -0
- package/mcp-server/dist/tools/tictactoe.js.map +1 -0
- package/mcp-server/dist/tools/utils.d.ts +10 -0
- package/mcp-server/dist/tools/utils.d.ts.map +1 -0
- package/mcp-server/dist/tools/utils.js +30 -0
- package/mcp-server/dist/tools/utils.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts +194 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.js +198 -0
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
- package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard.js +14 -0
- package/mcp-server/dist/tools/whiteboard.js.map +1 -0
- package/mcp-server/dist/tui.d.ts +3 -0
- package/mcp-server/dist/tui.d.ts.map +1 -0
- package/mcp-server/dist/tui.js +807 -0
- package/mcp-server/dist/tui.js.map +1 -0
- package/mcp-server/dist/types.d.ts +77 -0
- package/mcp-server/dist/types.d.ts.map +1 -0
- package/mcp-server/dist/types.js +2 -0
- package/mcp-server/dist/types.js.map +1 -0
- package/neural-interface/public/shared/styles.css +2 -2
- package/neural-interface/public/shared/ui-settings.js +2 -2
- package/neural-interface/server.js +82 -8
- package/neural-interface/templates/CLAUDE-template.md +9 -1
- package/package.json +4 -2
- package/preuninstall.js +28 -0
- package/setup.js +86 -1
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as ni from '../services/neural-interface.js';
|
|
3
|
+
import { text, image } from './response.js';
|
|
4
|
+
// ── browser_snapshot ──
|
|
5
|
+
export const browserSnapshotSchema = {
|
|
6
|
+
selector: z.string().optional().describe('Scope snapshot to a specific element\'s subtree. Dramatically reduces output on complex pages. Twitter: [data-testid="primaryColumn"] for main feed, [data-testid="tweet"] for a single tweet card.'),
|
|
7
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
8
|
+
};
|
|
9
|
+
export const browserSnapshotDescription = 'Get an accessibility tree snapshot of the current page. Returns a structured text representation of all visible elements with their ARIA roles, names, and values. This is the primary and most token-efficient way to "see" the page — prefer this over screenshots. ' +
|
|
10
|
+
'Twitter/X: scope to [data-testid="primaryColumn"] for main feed, [data-testid="tweet"] for a single tweet card. ' +
|
|
11
|
+
'Facebook: scope to [role="feed"] for group/page feed, [role="article"] for a single post, [role="dialog"] for the post composer dialog. ' +
|
|
12
|
+
'TikTok: scope to article for a single video in For You feed, [data-e2e="search_top-item-list"] for search results, [data-tt="components_PostTable_Container"] for Studio content list. ' +
|
|
13
|
+
'WhatsApp: scope to [aria-label="Lista de conversas"] for the chat list, [role="application"] for the open chat message view. ' +
|
|
14
|
+
'Instagram: scope to article for a single feed post, header for profile info, main for a post page with comments, form for the comment input area. ' +
|
|
15
|
+
'LinkedIn: scope to .feed-shared-update-v2[data-urn] for a single feed post, .scaffold-layout__main for main content, .msg-conversation-listitem for a messaging conversation, article.nt-card for a notification, [role="dialog"] for the post composer.';
|
|
16
|
+
// Roles that are pure structural containers with no semantic value when unnamed
|
|
17
|
+
const NOISE_ROLES = new Set(['none', 'presentation', 'generic']);
|
|
18
|
+
const MAX_DEPTH = 20;
|
|
19
|
+
function formatSnapshotNode(node, indent = 0) {
|
|
20
|
+
if (!node || indent > MAX_DEPTH)
|
|
21
|
+
return '';
|
|
22
|
+
const role = node.role || 'generic';
|
|
23
|
+
const children = node.children;
|
|
24
|
+
const hasChildren = children && children.length > 0;
|
|
25
|
+
// Skip pure noise nodes: unnamed container roles with no value and no children
|
|
26
|
+
if (NOISE_ROLES.has(role) && !node.name && !node.value && !hasChildren) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
const prefix = ' '.repeat(indent);
|
|
30
|
+
const name = node.name ? ` "${node.name}"` : '';
|
|
31
|
+
const value = node.value ? ` value="${node.value}"` : '';
|
|
32
|
+
const desc = node.description ? ` (${node.description})` : '';
|
|
33
|
+
const checked = node.checked !== undefined ? ` [${node.checked ? 'checked' : 'unchecked'}]` : '';
|
|
34
|
+
const selected = node.selected ? ' [selected]' : '';
|
|
35
|
+
const expanded = node.expanded !== undefined ? ` [${node.expanded ? 'expanded' : 'collapsed'}]` : '';
|
|
36
|
+
const disabled = node.disabled ? ' [disabled]' : '';
|
|
37
|
+
const focused = node.focused ? ' [focused]' : '';
|
|
38
|
+
let line = `${prefix}${role}${name}${value}${desc}${checked}${selected}${expanded}${disabled}${focused}`;
|
|
39
|
+
if (hasChildren) {
|
|
40
|
+
const childLines = children
|
|
41
|
+
.map(c => formatSnapshotNode(c, indent + 1))
|
|
42
|
+
.filter(s => s.length > 0)
|
|
43
|
+
.join('\n');
|
|
44
|
+
if (childLines)
|
|
45
|
+
line += '\n' + childLines;
|
|
46
|
+
}
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
export async function handleBrowserSnapshot(args) {
|
|
50
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
51
|
+
if ('error' in resolved)
|
|
52
|
+
return text(resolved.error);
|
|
53
|
+
const result = await ni.snapshot(resolved.sessionId, args.selector);
|
|
54
|
+
if (result.error)
|
|
55
|
+
return text(`Snapshot failed: ${result.error}`);
|
|
56
|
+
const tree = result.snapshot;
|
|
57
|
+
const formatted = tree ? formatSnapshotNode(tree) : '(empty page)';
|
|
58
|
+
let msg = `Page: ${result.url}\nTitle: "${result.title}"\n`;
|
|
59
|
+
if (args.selector)
|
|
60
|
+
msg += `Scope: ${args.selector}\n`;
|
|
61
|
+
msg += '\n' + formatted;
|
|
62
|
+
// Truncate if very long
|
|
63
|
+
if (msg.length > 30000) {
|
|
64
|
+
msg = msg.slice(0, 30000) + '\n... (truncated — narrow scope with selector param)';
|
|
65
|
+
}
|
|
66
|
+
return text(msg);
|
|
67
|
+
}
|
|
68
|
+
// ── browser_content ──
|
|
69
|
+
export const browserContentSchema = {
|
|
70
|
+
format: z.enum(['text', 'markdown']).optional().default('text').describe('Output format. "text" returns raw innerText (fast, no structure). "markdown" returns clean markdown with headings, links, and lists preserved — strips nav/header/footer/ads automatically. Markdown is better for LLM consumption.'),
|
|
71
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
72
|
+
};
|
|
73
|
+
export const browserContentDescription = 'Get the content of the current page. Set format="markdown" for clean markdown with structure preserved (headings, links, lists) — nav/header/footer/ads stripped automatically. Default format="text" returns raw visible text. Markdown is preferred for LLM consumption — up to 80% more token-efficient than raw HTML.';
|
|
74
|
+
export async function handleBrowserContent(args) {
|
|
75
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
76
|
+
if ('error' in resolved)
|
|
77
|
+
return text(resolved.error);
|
|
78
|
+
if (args.format === 'markdown') {
|
|
79
|
+
const result = await ni.getMarkdown(resolved.sessionId);
|
|
80
|
+
if (result.error)
|
|
81
|
+
return text(`Markdown extraction failed: ${result.error}`);
|
|
82
|
+
let msg = `URL: ${result.url}\nTitle: "${result.title}"\nTokens: ~${result.tokens}\n\n`;
|
|
83
|
+
msg += result.markdown || '(empty page)';
|
|
84
|
+
return text(msg);
|
|
85
|
+
}
|
|
86
|
+
// Default: plain text
|
|
87
|
+
const result = await ni.getContent(resolved.sessionId);
|
|
88
|
+
if (result.error)
|
|
89
|
+
return text(`Content failed: ${result.error}`);
|
|
90
|
+
let msg = `URL: ${result.url}\nTitle: "${result.title}"\n\n`;
|
|
91
|
+
msg += result.text || '(empty page)';
|
|
92
|
+
return text(msg);
|
|
93
|
+
}
|
|
94
|
+
// ── browser_screenshot ──
|
|
95
|
+
export const browserScreenshotSchema = {
|
|
96
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
97
|
+
};
|
|
98
|
+
export const browserScreenshotDescription = 'Take a screenshot of the current page. Returns a base64-encoded JPEG image. Use sparingly — prefer browser_snapshot for most tasks as it is far more token-efficient.';
|
|
99
|
+
export async function handleBrowserScreenshot(args) {
|
|
100
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
101
|
+
if ('error' in resolved)
|
|
102
|
+
return text(resolved.error);
|
|
103
|
+
const result = await ni.screenshot(resolved.sessionId);
|
|
104
|
+
if (result.error)
|
|
105
|
+
return text(`Screenshot failed: ${result.error}`);
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
...text(`Screenshot of ${result.url} — "${result.title}"`).content,
|
|
109
|
+
...image(result.data, 'image/jpeg').content,
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ── browser_extract_tweets ──
|
|
114
|
+
const TWEET_EXTRACTOR_SCRIPT = `
|
|
115
|
+
Array.from(document.querySelectorAll('[data-testid="tweet"]')).map(el => {
|
|
116
|
+
const userNameEl = el.querySelector('[data-testid="User-Name"]');
|
|
117
|
+
const lines = userNameEl ? userNameEl.innerText.split('\\n').filter(Boolean) : [];
|
|
118
|
+
const statusLink = el.querySelector('a[href*="/status/"]');
|
|
119
|
+
return {
|
|
120
|
+
author: lines[0] || null,
|
|
121
|
+
handle: lines.find(l => l.startsWith('@')) || null,
|
|
122
|
+
text: el.querySelector('[data-testid="tweetText"]')?.innerText || null,
|
|
123
|
+
time: el.querySelector('time')?.getAttribute('datetime') || null,
|
|
124
|
+
url: statusLink ? statusLink.href : null,
|
|
125
|
+
replies: el.querySelector('[data-testid="reply"] span[data-testid="app-text-transition-container"]')?.innerText || null,
|
|
126
|
+
reposts: el.querySelector('[data-testid="retweet"] span[data-testid="app-text-transition-container"]')?.innerText || null,
|
|
127
|
+
likes: el.querySelector('[data-testid="like"] span[data-testid="app-text-transition-container"]')?.innerText || null,
|
|
128
|
+
views: el.querySelector('a[href*="/analytics"] span')?.innerText || null,
|
|
129
|
+
};
|
|
130
|
+
})
|
|
131
|
+
`.trim();
|
|
132
|
+
export const browserExtractTweetsSchema = {
|
|
133
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
134
|
+
};
|
|
135
|
+
export const browserExtractTweetsDescription = 'Extract all currently visible tweets as structured JSON (author, handle, text, time, url, replies, reposts, likes, views). Much faster than browser_snapshot for data harvesting — use this in scraping/loop flows instead of reading the ARIA tree. Navigate to x.com/search?q=%23hashtag&f=live first for latest-first hashtag results.';
|
|
136
|
+
export async function handleBrowserExtractTweets(args) {
|
|
137
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
138
|
+
if ('error' in resolved)
|
|
139
|
+
return text(resolved.error);
|
|
140
|
+
const result = await ni.evaluate(resolved.sessionId, TWEET_EXTRACTOR_SCRIPT);
|
|
141
|
+
if (result.error)
|
|
142
|
+
return text(`Extract failed: ${result.error}`);
|
|
143
|
+
const tweets = result.result;
|
|
144
|
+
if (!Array.isArray(tweets) || tweets.length === 0) {
|
|
145
|
+
return text(`No tweets found. Try browser_scroll then retry.`);
|
|
146
|
+
}
|
|
147
|
+
const json = JSON.stringify(tweets, null, 2);
|
|
148
|
+
return text(`${tweets.length} tweet(s):\n\n${json}`);
|
|
149
|
+
}
|
|
150
|
+
// ── browser_extract_fb_posts ──
|
|
151
|
+
const FB_POST_EXTRACTOR_SCRIPT = `
|
|
152
|
+
Array.from(document.querySelectorAll('[role="article"]')).map(el => {
|
|
153
|
+
const h2Link = el.querySelector('h2 a, h3 a, h4 a, strong a');
|
|
154
|
+
const timeLink = el.querySelector('a[href*="?__cft__"], a[href*="/posts/"], a[href*="/permalink/"]');
|
|
155
|
+
const abbr = el.querySelector('abbr[title], abbr[data-utime]');
|
|
156
|
+
const textEl = el.querySelector('[data-ad-comet-preview="message"], [data-ad-preview="message"]');
|
|
157
|
+
const reactEl = el.querySelector('[aria-label*="reaction"], [aria-label*="reação"], [aria-label*="tepki"]');
|
|
158
|
+
return {
|
|
159
|
+
author: h2Link ? h2Link.textContent.trim() : null,
|
|
160
|
+
authorUrl: h2Link ? h2Link.href : null,
|
|
161
|
+
text: textEl ? textEl.innerText.trim() : (el.querySelector('[dir="auto"]')?.innerText?.trim() || null),
|
|
162
|
+
time: abbr ? (abbr.getAttribute('title') || abbr.textContent.trim()) : (timeLink ? timeLink.textContent.trim() : null),
|
|
163
|
+
postUrl: timeLink ? timeLink.href : null,
|
|
164
|
+
reactions: reactEl ? reactEl.getAttribute('aria-label') : null,
|
|
165
|
+
};
|
|
166
|
+
}).filter(p => p.author || p.text)
|
|
167
|
+
`.trim();
|
|
168
|
+
export const browserExtractFbPostsSchema = {
|
|
169
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
170
|
+
};
|
|
171
|
+
export const browserExtractFbPostsDescription = 'Extract all currently visible Facebook posts as structured JSON (author, authorUrl, text, time, postUrl, reactions). ' +
|
|
172
|
+
'Works on group feeds and Pages. Scroll down first with browser_scroll to load more posts. ' +
|
|
173
|
+
'Much faster than browser_snapshot for data harvesting from Facebook.';
|
|
174
|
+
export async function handleBrowserExtractFbPosts(args) {
|
|
175
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
176
|
+
if ('error' in resolved)
|
|
177
|
+
return text(resolved.error);
|
|
178
|
+
const result = await ni.evaluate(resolved.sessionId, FB_POST_EXTRACTOR_SCRIPT);
|
|
179
|
+
if (result.error)
|
|
180
|
+
return text(`Extract failed: ${result.error}`);
|
|
181
|
+
const posts = result.result;
|
|
182
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
183
|
+
return text('No posts found. Try browser_scroll down then retry.');
|
|
184
|
+
}
|
|
185
|
+
return text(`${posts.length} post(s):\n\n${JSON.stringify(posts, null, 2)}`);
|
|
186
|
+
}
|
|
187
|
+
// ── browser_extract_tiktok_videos ──
|
|
188
|
+
const TIKTOK_FEED_EXTRACTOR_SCRIPT = `
|
|
189
|
+
(() => {
|
|
190
|
+
// Build a map of handle -> video metadata from global state (gives videoUrl, caption, music)
|
|
191
|
+
const globalItems = window['__$UNIVERSAL_DATA$__']?.['__DEFAULT_SCOPE__']?.['webapp.updated-items'] || {};
|
|
192
|
+
const stateMap = {};
|
|
193
|
+
for (const key of Object.keys(globalItems)) {
|
|
194
|
+
const item = globalItems[key];
|
|
195
|
+
const h = item?.author?.uniqueId;
|
|
196
|
+
if (h) stateMap[h] = { videoUrl: 'https://www.tiktok.com/@' + h + '/video/' + item.id, caption: item.desc || null, music: item.music?.title || null };
|
|
197
|
+
}
|
|
198
|
+
// Merge DOM counts (live) with state metadata
|
|
199
|
+
return Array.from(document.querySelectorAll('article')).filter(el =>
|
|
200
|
+
el.querySelector('[data-e2e="like-count"]')
|
|
201
|
+
).map(el => {
|
|
202
|
+
const handle = el.querySelector('a[href^="/@"]')?.getAttribute('href')?.replace('/@','')?.split('?')[0] || null;
|
|
203
|
+
const state = handle ? (stateMap[handle] || {}) : {};
|
|
204
|
+
const likes = el.querySelector('[data-e2e="like-count"]')?.innerText?.trim() || null;
|
|
205
|
+
const comments = el.querySelector('[data-e2e="comment-count"]')?.innerText?.trim() || null;
|
|
206
|
+
const saves = el.querySelector('[data-e2e="undefined-count"]')?.innerText?.trim() || null;
|
|
207
|
+
const shares = el.querySelector('[data-e2e="share-count"]')?.innerText?.trim() || null;
|
|
208
|
+
const caption = state.caption || el.querySelector('[data-e2e="video-desc"]')?.innerText?.trim() || null;
|
|
209
|
+
return { handle, videoUrl: state.videoUrl || null, caption, likes, comments, saves, shares, music: state.music || null };
|
|
210
|
+
}).filter(v => v.handle);
|
|
211
|
+
})()
|
|
212
|
+
`.trim();
|
|
213
|
+
export const browserExtractTiktokVideosSchema = {
|
|
214
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
215
|
+
};
|
|
216
|
+
export const browserExtractTiktokVideosDescription = 'Extract all currently visible TikTok videos from the For You or Following feed as structured JSON ' +
|
|
217
|
+
'(handle, videoUrl, caption, likes, comments, saves, shares, music). ' +
|
|
218
|
+
'Navigate to tiktok.com/ or tiktok.com/following first. Scroll with browser_scroll to load more videos. ' +
|
|
219
|
+
'Much faster than browser_snapshot for data harvesting from TikTok feeds.';
|
|
220
|
+
export async function handleBrowserExtractTiktokVideos(args) {
|
|
221
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
222
|
+
if ('error' in resolved)
|
|
223
|
+
return text(resolved.error);
|
|
224
|
+
const result = await ni.evaluate(resolved.sessionId, TIKTOK_FEED_EXTRACTOR_SCRIPT);
|
|
225
|
+
if (result.error)
|
|
226
|
+
return text(`Extract failed: ${result.error}`);
|
|
227
|
+
const videos = result.result;
|
|
228
|
+
if (!Array.isArray(videos) || videos.length === 0) {
|
|
229
|
+
return text('No TikTok videos found. Make sure you are on tiktok.com/ or tiktok.com/following, then try browser_scroll down and retry.');
|
|
230
|
+
}
|
|
231
|
+
return text(`${videos.length} video(s):\n\n${JSON.stringify(videos, null, 2)}`);
|
|
232
|
+
}
|
|
233
|
+
// ── browser_extract_tiktok_search ──
|
|
234
|
+
const TIKTOK_SEARCH_EXTRACTOR_SCRIPT = `
|
|
235
|
+
Array.from(document.querySelectorAll('[data-e2e="search_top-item"]')).map(el => {
|
|
236
|
+
const videoLink = el.querySelector('a[href*="/video/"]');
|
|
237
|
+
const userLink = el.querySelector('[data-e2e="search-card-user-link"]');
|
|
238
|
+
const caption = el.querySelector('[data-e2e="search-card-video-caption"]');
|
|
239
|
+
const uniqueId = el.querySelector('[data-e2e="search-card-user-unique-id"]');
|
|
240
|
+
const views = el.querySelector('[data-e2e="video-views"]');
|
|
241
|
+
return {
|
|
242
|
+
videoUrl: videoLink ? videoLink.href : null,
|
|
243
|
+
handle: uniqueId ? uniqueId.innerText.trim() : null,
|
|
244
|
+
profileUrl: userLink ? userLink.href : null,
|
|
245
|
+
caption: caption ? caption.innerText.trim() : null,
|
|
246
|
+
views: views ? views.innerText.trim() : null,
|
|
247
|
+
};
|
|
248
|
+
}).filter(v => v.videoUrl)
|
|
249
|
+
`.trim();
|
|
250
|
+
export const browserExtractTiktokSearchSchema = {
|
|
251
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
252
|
+
};
|
|
253
|
+
export const browserExtractTiktokSearchDescription = 'Extract all currently visible TikTok search result videos as structured JSON ' +
|
|
254
|
+
'(videoUrl, handle, profileUrl, caption, views). ' +
|
|
255
|
+
'Navigate to tiktok.com/search?q=<query> first. Scroll to load more results. ' +
|
|
256
|
+
'Much faster than browser_snapshot for harvesting TikTok search results.';
|
|
257
|
+
export async function handleBrowserExtractTiktokSearch(args) {
|
|
258
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
259
|
+
if ('error' in resolved)
|
|
260
|
+
return text(resolved.error);
|
|
261
|
+
const result = await ni.evaluate(resolved.sessionId, TIKTOK_SEARCH_EXTRACTOR_SCRIPT);
|
|
262
|
+
if (result.error)
|
|
263
|
+
return text(`Extract failed: ${result.error}`);
|
|
264
|
+
const videos = result.result;
|
|
265
|
+
if (!Array.isArray(videos) || videos.length === 0) {
|
|
266
|
+
return text('No search results found. Make sure you are on tiktok.com/search?q=... then retry.');
|
|
267
|
+
}
|
|
268
|
+
return text(`${videos.length} result(s):\n\n${JSON.stringify(videos, null, 2)}`);
|
|
269
|
+
}
|
|
270
|
+
// ── browser_extract_tiktok_studio ──
|
|
271
|
+
const TIKTOK_STUDIO_EXTRACTOR_SCRIPT = `
|
|
272
|
+
Array.from(document.querySelectorAll('[data-tt="components_PostInfoCell_a"]')).map(link => {
|
|
273
|
+
const row = link.closest('[data-tt="components_RowLayout_FlexRow"]') ||
|
|
274
|
+
link.closest('[data-tt="components_ItemRow_FlexRow"]') ||
|
|
275
|
+
link.parentElement?.closest('[class*="FlexRow"]');
|
|
276
|
+
const dateEl = row?.querySelector('[data-tt="components_PublishStageLabel_TUXText"]');
|
|
277
|
+
const privacyBtn = row?.querySelector('[data-tt="components_PrivacyCell_TUXButton"]');
|
|
278
|
+
const statEls = row ? Array.from(row.querySelectorAll('[data-tt="components_ItemRow_TUXText"]')) : [];
|
|
279
|
+
const stats = statEls.map(el => el.innerText.trim()).filter(Boolean);
|
|
280
|
+
return {
|
|
281
|
+
title: link.innerText.trim(),
|
|
282
|
+
url: link.href,
|
|
283
|
+
date: dateEl ? dateEl.innerText.trim() : null,
|
|
284
|
+
privacy: privacyBtn ? privacyBtn.innerText.trim() : null,
|
|
285
|
+
stats,
|
|
286
|
+
};
|
|
287
|
+
}).filter(p => p.title)
|
|
288
|
+
`.trim();
|
|
289
|
+
export const browserExtractTiktokStudioSchema = {
|
|
290
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
291
|
+
};
|
|
292
|
+
export const browserExtractTiktokStudioDescription = 'Extract all visible posts from TikTok Studio content list as structured JSON ' +
|
|
293
|
+
'(title, url, date, privacy, stats[]). ' +
|
|
294
|
+
'Navigate to tiktok.com/tiktokstudio/content first. Scroll to load more posts. ' +
|
|
295
|
+
'Use this to audit, manage, or bulk-read your published TikTok content.';
|
|
296
|
+
export async function handleBrowserExtractTiktokStudio(args) {
|
|
297
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
298
|
+
if ('error' in resolved)
|
|
299
|
+
return text(resolved.error);
|
|
300
|
+
const result = await ni.evaluate(resolved.sessionId, TIKTOK_STUDIO_EXTRACTOR_SCRIPT);
|
|
301
|
+
if (result.error)
|
|
302
|
+
return text(`Extract failed: ${result.error}`);
|
|
303
|
+
const posts = result.result;
|
|
304
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
305
|
+
return text('No Studio posts found. Make sure you are on tiktok.com/tiktokstudio/content then retry.');
|
|
306
|
+
}
|
|
307
|
+
return text(`${posts.length} post(s):\n\n${JSON.stringify(posts, null, 2)}`);
|
|
308
|
+
}
|
|
309
|
+
// ── browser_extract_tiktok_profile ──
|
|
310
|
+
const TIKTOK_PROFILE_EXTRACTOR_SCRIPT = `
|
|
311
|
+
(() => {
|
|
312
|
+
const name = document.querySelector('[data-e2e="user-title"]')?.innerText?.trim() || null;
|
|
313
|
+
const handle = document.querySelector('[data-e2e="user-subtitle"]')?.innerText?.trim() || null;
|
|
314
|
+
const bio = document.querySelector('[data-e2e="user-bio"]')?.innerText?.trim() || null;
|
|
315
|
+
const followers = document.querySelector('[data-e2e="followers-count"]')?.innerText?.trim() || null;
|
|
316
|
+
const following = document.querySelector('[data-e2e="following-count"]')?.innerText?.trim() || null;
|
|
317
|
+
const likes = document.querySelector('[data-e2e="likes-count"]')?.innerText?.trim() || null;
|
|
318
|
+
const videoItems = Array.from(document.querySelectorAll('[data-e2e="user-post-item"]')).map(el => {
|
|
319
|
+
const link = el.querySelector('a[href*="/video/"]');
|
|
320
|
+
const views = el.querySelector('[data-e2e="video-views"]');
|
|
321
|
+
return {
|
|
322
|
+
videoUrl: link ? link.href : null,
|
|
323
|
+
views: views ? views.innerText.trim() : null,
|
|
324
|
+
};
|
|
325
|
+
}).filter(v => v.videoUrl);
|
|
326
|
+
return { name, handle, bio, followers, following, likes, videos: videoItems };
|
|
327
|
+
})()
|
|
328
|
+
`.trim();
|
|
329
|
+
export const browserExtractTiktokProfileSchema = {
|
|
330
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
331
|
+
};
|
|
332
|
+
export const browserExtractTiktokProfileDescription = 'Extract profile info and video grid from a TikTok profile page as structured JSON ' +
|
|
333
|
+
'(name, handle, bio, followers, following, likes, videos[{videoUrl, views}]). ' +
|
|
334
|
+
'Navigate to tiktok.com/@username first. Scroll down to load more videos in the grid. ' +
|
|
335
|
+
'Use this to audit a creator profile or collect video URLs for further processing.';
|
|
336
|
+
export async function handleBrowserExtractTiktokProfile(args) {
|
|
337
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
338
|
+
if ('error' in resolved)
|
|
339
|
+
return text(resolved.error);
|
|
340
|
+
const result = await ni.evaluate(resolved.sessionId, TIKTOK_PROFILE_EXTRACTOR_SCRIPT);
|
|
341
|
+
if (result.error)
|
|
342
|
+
return text(`Extract failed: ${result.error}`);
|
|
343
|
+
const profile = result.result;
|
|
344
|
+
if (!profile || (!profile.name && !profile.handle)) {
|
|
345
|
+
return text('No profile found. Make sure you are on tiktok.com/@username then retry.');
|
|
346
|
+
}
|
|
347
|
+
return text(`Profile:\n\n${JSON.stringify(profile, null, 2)}`);
|
|
348
|
+
}
|
|
349
|
+
// ── browser_extract_wa_chats ──
|
|
350
|
+
const WA_CHATS_EXTRACTOR_SCRIPT = `
|
|
351
|
+
Array.from(document.querySelectorAll('[aria-label="Lista de conversas"] [role="row"]')).map(row => {
|
|
352
|
+
const nameSpan = row.querySelector('span[title][dir="auto"]');
|
|
353
|
+
const allSpans = Array.from(row.querySelectorAll('span')).filter(s => !s.children.length && s.innerText?.trim());
|
|
354
|
+
const timeSpan = allSpans.find(s => /^\\d|Ontem|Hoje|Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/.test(s.innerText.trim()));
|
|
355
|
+
const msgSpans = Array.from(row.querySelectorAll('span[dir="auto"]')).filter(s => !s.getAttribute('title') && s.innerText?.trim());
|
|
356
|
+
const unreadEl = row.querySelector('[aria-label*="mensagem não lida"]');
|
|
357
|
+
const mutedEl = row.querySelector('[aria-label="Conversa silenciada"]');
|
|
358
|
+
const pinnedEl = row.querySelector('[aria-label="Conversa fixada"]');
|
|
359
|
+
const lastMsg = msgSpans.map(s => s.innerText.trim()).filter(t => t && t !== nameSpan?.innerText?.trim()).join(' ').substring(0, 80) || null;
|
|
360
|
+
return {
|
|
361
|
+
name: nameSpan?.getAttribute('title') || nameSpan?.innerText?.trim() || null,
|
|
362
|
+
lastMsg,
|
|
363
|
+
time: timeSpan?.innerText?.trim() || null,
|
|
364
|
+
unreadCount: unreadEl?.innerText?.trim() || null,
|
|
365
|
+
muted: !!mutedEl,
|
|
366
|
+
pinned: !!pinnedEl,
|
|
367
|
+
};
|
|
368
|
+
}).filter(c => c.name)
|
|
369
|
+
`.trim();
|
|
370
|
+
export const browserExtractWaChatsSchema = {
|
|
371
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
372
|
+
};
|
|
373
|
+
export const browserExtractWaChatsDescription = 'Extract all currently visible WhatsApp chats from the sidebar as structured JSON ' +
|
|
374
|
+
'(name, lastMsg, time, unreadCount, muted, pinned). ' +
|
|
375
|
+
'Must be on web.whatsapp.com with the chat list visible. Scroll the sidebar with browser_scroll to load more chats. ' +
|
|
376
|
+
'Use browser_click on span[title="Chat Name"] to open a specific chat.';
|
|
377
|
+
export async function handleBrowserExtractWaChats(args) {
|
|
378
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
379
|
+
if ('error' in resolved)
|
|
380
|
+
return text(resolved.error);
|
|
381
|
+
const result = await ni.evaluate(resolved.sessionId, WA_CHATS_EXTRACTOR_SCRIPT);
|
|
382
|
+
if (result.error)
|
|
383
|
+
return text(`Extract failed: ${result.error}`);
|
|
384
|
+
const chats = result.result;
|
|
385
|
+
if (!Array.isArray(chats) || chats.length === 0) {
|
|
386
|
+
return text('No chats found. Make sure you are on web.whatsapp.com with the chat list visible.');
|
|
387
|
+
}
|
|
388
|
+
return text(`${chats.length} chat(s):\n\n${JSON.stringify(chats, null, 2)}`);
|
|
389
|
+
}
|
|
390
|
+
// ── browser_extract_wa_messages ──
|
|
391
|
+
const WA_MESSAGES_EXTRACTOR_SCRIPT = `
|
|
392
|
+
Array.from(document.querySelectorAll('div.copyable-text[data-pre-plain-text]')).map(el => {
|
|
393
|
+
const meta = el.getAttribute('data-pre-plain-text') || '';
|
|
394
|
+
const timeMatch = meta.match(/\\[([^,]+),\\s*([^\\]]+)\\]/);
|
|
395
|
+
const senderMatch = meta.match(/\\]\\s*([^:]+):/);
|
|
396
|
+
const isOut = !!el.closest('.message-out');
|
|
397
|
+
const dataId = el.closest('[data-id]')?.getAttribute('data-id') || null;
|
|
398
|
+
const textContent = el.innerText?.trim() || null;
|
|
399
|
+
return {
|
|
400
|
+
sender: senderMatch ? senderMatch[1].trim() : (isOut ? 'Me' : null),
|
|
401
|
+
time: timeMatch ? timeMatch[1].trim() : null,
|
|
402
|
+
date: timeMatch ? timeMatch[2].trim() : null,
|
|
403
|
+
direction: isOut ? 'out' : 'in',
|
|
404
|
+
text: textContent,
|
|
405
|
+
dataId,
|
|
406
|
+
};
|
|
407
|
+
}).filter(m => m.text)
|
|
408
|
+
`.trim();
|
|
409
|
+
export const browserExtractWaMessagesSchema = {
|
|
410
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
411
|
+
};
|
|
412
|
+
export const browserExtractWaMessagesDescription = 'Extract all currently visible messages from the open WhatsApp chat as structured JSON ' +
|
|
413
|
+
'(sender, time, date, direction, text, dataId). ' +
|
|
414
|
+
'Open a chat first by clicking span[title="Chat Name"]. Scroll up with browser_scroll to load older messages. ' +
|
|
415
|
+
'direction is "in" for received and "out" for sent messages.';
|
|
416
|
+
export async function handleBrowserExtractWaMessages(args) {
|
|
417
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
418
|
+
if ('error' in resolved)
|
|
419
|
+
return text(resolved.error);
|
|
420
|
+
const result = await ni.evaluate(resolved.sessionId, WA_MESSAGES_EXTRACTOR_SCRIPT);
|
|
421
|
+
if (result.error)
|
|
422
|
+
return text(`Extract failed: ${result.error}`);
|
|
423
|
+
const messages = result.result;
|
|
424
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
425
|
+
return text('No messages found. Open a chat first, then retry.');
|
|
426
|
+
}
|
|
427
|
+
return text(`${messages.length} message(s):\n\n${JSON.stringify(messages, null, 2)}`);
|
|
428
|
+
}
|
|
429
|
+
// ── browser_extract_ig_feed ──
|
|
430
|
+
const IG_FEED_EXTRACTOR_SCRIPT = `
|
|
431
|
+
Array.from(document.querySelectorAll('article')).map(el => {
|
|
432
|
+
const userLink = Array.from(el.querySelectorAll('a[href^="/"]')).find(a => a.getAttribute('href')?.match(/^\\/[^/]+\\/$/) && !a.getAttribute('href').includes('/p/') && !a.getAttribute('href').includes('/reel/') && !a.getAttribute('href').includes('/explore/'));
|
|
433
|
+
const username = userLink?.textContent?.trim() || null;
|
|
434
|
+
const profileUrl = userLink ? 'https://www.instagram.com' + userLink.getAttribute('href') : null;
|
|
435
|
+
const postLink = el.querySelector('a[href*="/p/"], a[href*="/reel/"]');
|
|
436
|
+
const postUrl = postLink ? 'https://www.instagram.com' + postLink.getAttribute('href') : null;
|
|
437
|
+
const captionEl = el.querySelector('span._ap3a._aaco._aacu._aacx._aad7._aade');
|
|
438
|
+
const caption = captionEl?.innerText?.trim() || null;
|
|
439
|
+
const countSpans = Array.from(el.querySelectorAll('section span')).filter(s => s.children.length === 0 && /^[\\d.,]+\\s*(mil|M|K|B)?$/.test(s.textContent?.trim() || ''));
|
|
440
|
+
const likes = countSpans[0]?.textContent?.trim() || null;
|
|
441
|
+
const comments = countSpans[1]?.textContent?.trim() || null;
|
|
442
|
+
const timeEl = el.querySelector('time');
|
|
443
|
+
const time = timeEl?.textContent?.trim() || null;
|
|
444
|
+
const datetime = timeEl?.getAttribute('datetime') || null;
|
|
445
|
+
const isSponsored = !!Array.from(el.querySelectorAll('span')).find(s => s.textContent?.trim() === 'Patrocinado' || s.textContent?.trim() === 'Sponsored');
|
|
446
|
+
const hasFollow = !!Array.from(el.querySelectorAll('*')).find(e => (e.textContent?.trim() === 'Seguir' || e.textContent?.trim() === 'Follow') && e.children.length === 0);
|
|
447
|
+
return { username, profileUrl, postUrl, caption, likes, comments, time, datetime, isSponsored, hasFollow };
|
|
448
|
+
}).filter(p => p.username || p.caption)
|
|
449
|
+
`;
|
|
450
|
+
export const browserExtractIgFeedSchema = {
|
|
451
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
452
|
+
};
|
|
453
|
+
export const browserExtractIgFeedDescription = 'Extract all currently visible Instagram feed posts as structured JSON ' +
|
|
454
|
+
'(username, profileUrl, postUrl, caption, likes, comments, time, datetime, isSponsored, hasFollow). ' +
|
|
455
|
+
'Navigate to instagram.com/ first. Scroll down with browser_scroll to load more posts.';
|
|
456
|
+
export async function handleBrowserExtractIgFeed(args) {
|
|
457
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
458
|
+
if ('error' in resolved)
|
|
459
|
+
return text(resolved.error);
|
|
460
|
+
const result = await ni.evaluate(resolved.sessionId, IG_FEED_EXTRACTOR_SCRIPT);
|
|
461
|
+
if (result.error)
|
|
462
|
+
return text(`Extract failed: ${result.error}`);
|
|
463
|
+
const posts = result.result;
|
|
464
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
465
|
+
return text('No feed posts found. Navigate to instagram.com/ and scroll down, then retry.');
|
|
466
|
+
}
|
|
467
|
+
return text(`${posts.length} post(s):\n\n${JSON.stringify(posts, null, 2)}`);
|
|
468
|
+
}
|
|
469
|
+
// ── browser_extract_ig_profile ──
|
|
470
|
+
const IG_PROFILE_EXTRACTOR_SCRIPT = `
|
|
471
|
+
(() => {
|
|
472
|
+
const header = document.querySelector('header');
|
|
473
|
+
if (!header) return null;
|
|
474
|
+
const username = header.querySelector('h2')?.textContent?.trim() || header.querySelector('h1')?.textContent?.trim() || null;
|
|
475
|
+
const nameSpan = header.querySelector('span[dir="auto"]');
|
|
476
|
+
const displayName = nameSpan?.textContent?.trim() || null;
|
|
477
|
+
const statTexts = Array.from(header.querySelectorAll('span')).filter(s => {
|
|
478
|
+
const t = s.textContent?.trim() || '';
|
|
479
|
+
return t.includes('post') || t.includes('seguidore') || t.includes('seguind') || t.includes('follower') || t.includes('following');
|
|
480
|
+
});
|
|
481
|
+
const posts = statTexts.find(s => /post/i.test(s.textContent))?.textContent?.trim() || null;
|
|
482
|
+
const followers = statTexts.find(s => /seguidore|follower/i.test(s.textContent))?.textContent?.trim() || null;
|
|
483
|
+
const following = statTexts.find(s => /seguind|following/i.test(s.textContent))?.textContent?.trim() || null;
|
|
484
|
+
const followerExact = header.querySelector('span[title]')?.getAttribute('title') || null;
|
|
485
|
+
const bioSpans = Array.from(header.querySelectorAll('span[dir="auto"]')).filter(s => {
|
|
486
|
+
const t = s.textContent?.trim();
|
|
487
|
+
return t && t !== username && t !== displayName && t.length > 2 && !/(post|seguidore|seguind|follower|following)/i.test(t);
|
|
488
|
+
});
|
|
489
|
+
const bio = bioSpans.map(s => s.textContent?.trim()).join('\\n') || null;
|
|
490
|
+
const extLink = header.querySelector('a[href*="l.instagram.com"]');
|
|
491
|
+
const website = extLink?.textContent?.trim() || null;
|
|
492
|
+
const isVerified = !!header.querySelector('svg[aria-label="Verificado"], svg[aria-label="Verified"]');
|
|
493
|
+
const gridPosts = Array.from(document.querySelectorAll('a[href*="/p/"], a[href*="/reel/"]')).slice(0, 12).map(a => ({
|
|
494
|
+
url: 'https://www.instagram.com' + a.getAttribute('href'),
|
|
495
|
+
alt: a.querySelector('img')?.getAttribute('alt')?.substring(0, 100) || null,
|
|
496
|
+
isReel: a.getAttribute('href').includes('/reel/'),
|
|
497
|
+
}));
|
|
498
|
+
const highlights = Array.from(document.querySelectorAll('a[href*="/stories/highlights/"]')).map(a => ({
|
|
499
|
+
name: a.textContent?.trim() || null,
|
|
500
|
+
url: 'https://www.instagram.com' + a.getAttribute('href'),
|
|
501
|
+
}));
|
|
502
|
+
return { username, displayName, bio, posts, followers, followerExact, following, isVerified, website, gridPosts, highlights };
|
|
503
|
+
})()
|
|
504
|
+
`;
|
|
505
|
+
export const browserExtractIgProfileSchema = {
|
|
506
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
507
|
+
};
|
|
508
|
+
export const browserExtractIgProfileDescription = 'Extract Instagram profile data as structured JSON ' +
|
|
509
|
+
'(username, displayName, bio, posts, followers, followerExact, following, isVerified, website, gridPosts, highlights). ' +
|
|
510
|
+
'Navigate to instagram.com/username/ first. Returns bio, stats, post grid (up to 12), and story highlights.';
|
|
511
|
+
export async function handleBrowserExtractIgProfile(args) {
|
|
512
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
513
|
+
if ('error' in resolved)
|
|
514
|
+
return text(resolved.error);
|
|
515
|
+
const result = await ni.evaluate(resolved.sessionId, IG_PROFILE_EXTRACTOR_SCRIPT);
|
|
516
|
+
if (result.error)
|
|
517
|
+
return text(`Extract failed: ${result.error}`);
|
|
518
|
+
const profile = result.result;
|
|
519
|
+
if (!profile || !profile.username) {
|
|
520
|
+
return text('No profile data found. Navigate to instagram.com/username/ first, then retry.');
|
|
521
|
+
}
|
|
522
|
+
return text(`Profile:\n\n${JSON.stringify(profile, null, 2)}`);
|
|
523
|
+
}
|
|
524
|
+
// ── browser_extract_ig_post ──
|
|
525
|
+
const IG_POST_EXTRACTOR_SCRIPT = `
|
|
526
|
+
(() => {
|
|
527
|
+
const main = document.querySelector('main');
|
|
528
|
+
if (!main) return null;
|
|
529
|
+
const authorLink = Array.from(main.querySelectorAll('a[role="link"]')).find(a => a.getAttribute('href')?.match(/^\\/[^/]+\\/$/));
|
|
530
|
+
const author = authorLink?.textContent?.trim() || null;
|
|
531
|
+
let caption = null;
|
|
532
|
+
if (authorLink) {
|
|
533
|
+
const container = authorLink.parentElement?.parentElement;
|
|
534
|
+
if (container) {
|
|
535
|
+
const spans = Array.from(container.querySelectorAll('span[dir="auto"]'));
|
|
536
|
+
caption = spans.map(s => s.textContent?.trim()).filter(t => t && t !== author).join(' ')?.substring(0, 500) || null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const countSpans = Array.from(main.querySelectorAll('section span')).filter(s => s.children.length === 0 && /^[\\d.,]+\\s*(mil|M|K|B)?$/.test(s.textContent?.trim() || ''));
|
|
540
|
+
const likes = countSpans.length >= 2 ? countSpans[countSpans.length - 2]?.textContent?.trim() : (countSpans[0]?.textContent?.trim() || null);
|
|
541
|
+
const commentCount = countSpans.length >= 2 ? countSpans[countSpans.length - 1]?.textContent?.trim() : null;
|
|
542
|
+
const timeEls = Array.from(main.querySelectorAll('time'));
|
|
543
|
+
const postTime = timeEls.find(t => t.textContent?.trim()?.startsWith('há') || t.textContent?.trim()?.startsWith('ago') || /^\\d/.test(t.textContent?.trim() || ''));
|
|
544
|
+
const time = postTime?.textContent?.trim() || timeEls[0]?.textContent?.trim() || null;
|
|
545
|
+
const datetime = postTime?.getAttribute('datetime') || timeEls[0]?.getAttribute('datetime') || null;
|
|
546
|
+
const commentLinks = Array.from(main.querySelectorAll('a[href*="/c/"]'));
|
|
547
|
+
const comments = [];
|
|
548
|
+
const seen = new Set();
|
|
549
|
+
for (const link of commentLinks) {
|
|
550
|
+
const commentUrl = link.getAttribute('href');
|
|
551
|
+
if (seen.has(commentUrl)) continue;
|
|
552
|
+
seen.add(commentUrl);
|
|
553
|
+
const row = link.parentElement?.parentElement?.parentElement;
|
|
554
|
+
if (!row) continue;
|
|
555
|
+
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
|
|
556
|
+
const texts = [];
|
|
557
|
+
let node;
|
|
558
|
+
while (node = walker.nextNode()) {
|
|
559
|
+
const t = node.textContent?.trim();
|
|
560
|
+
if (t) texts.push(t);
|
|
561
|
+
}
|
|
562
|
+
const cTime = row.querySelector('time');
|
|
563
|
+
comments.push({
|
|
564
|
+
username: texts[0] || null,
|
|
565
|
+
text: texts.slice(2).join(' ')?.substring(0, 200) || texts[1] || null,
|
|
566
|
+
time: cTime?.textContent?.trim() || null,
|
|
567
|
+
datetime: cTime?.getAttribute('datetime') || null,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return { author, caption, likes, commentCount, time, datetime, comments };
|
|
571
|
+
})()
|
|
572
|
+
`;
|
|
573
|
+
export const browserExtractIgPostSchema = {
|
|
574
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
575
|
+
};
|
|
576
|
+
export const browserExtractIgPostDescription = 'Extract a single Instagram post with comments as structured JSON ' +
|
|
577
|
+
'(author, caption, likes, commentCount, time, datetime, comments[{username, text, time, datetime}]). ' +
|
|
578
|
+
'Navigate to instagram.com/p/POST_ID/ or instagram.com/reel/REEL_ID/ first. ' +
|
|
579
|
+
'Scroll the comment area to load more comments before extracting.';
|
|
580
|
+
export async function handleBrowserExtractIgPost(args) {
|
|
581
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
582
|
+
if ('error' in resolved)
|
|
583
|
+
return text(resolved.error);
|
|
584
|
+
const result = await ni.evaluate(resolved.sessionId, IG_POST_EXTRACTOR_SCRIPT);
|
|
585
|
+
if (result.error)
|
|
586
|
+
return text(`Extract failed: ${result.error}`);
|
|
587
|
+
const post = result.result;
|
|
588
|
+
if (!post || !post.author) {
|
|
589
|
+
return text('No post data found. Navigate to instagram.com/p/POST_ID/ first, then retry.');
|
|
590
|
+
}
|
|
591
|
+
return text(`Post:\n\n${JSON.stringify(post, null, 2)}`);
|
|
592
|
+
}
|
|
593
|
+
// ── browser_extract_ig_reels ──
|
|
594
|
+
const IG_REELS_EXTRACTOR_SCRIPT = `
|
|
595
|
+
(() => {
|
|
596
|
+
const main = document.querySelector('main');
|
|
597
|
+
if (!main) return [];
|
|
598
|
+
const likeSvgs = Array.from(main.querySelectorAll('svg[aria-label="Curtir"], svg[aria-label="Like"]'));
|
|
599
|
+
const reels = [];
|
|
600
|
+
const seen = new Set();
|
|
601
|
+
for (const svg of likeSvgs) {
|
|
602
|
+
let container = svg.closest('[role="button"]')?.parentElement;
|
|
603
|
+
for (let i = 0; i < 5; i++) {
|
|
604
|
+
if (!container) break;
|
|
605
|
+
const hasComment = container.querySelector('svg[aria-label="Comentar"], svg[aria-label="Comment"]');
|
|
606
|
+
const hasShare = container.querySelector('svg[aria-label="Compartilhar"], svg[aria-label="Share"]');
|
|
607
|
+
if (hasComment && hasShare) break;
|
|
608
|
+
container = container.parentElement;
|
|
609
|
+
}
|
|
610
|
+
if (!container || seen.has(container)) continue;
|
|
611
|
+
seen.add(container);
|
|
612
|
+
const userLink = container.querySelector('a[href^="/"]');
|
|
613
|
+
const spans = Array.from(container.querySelectorAll('span')).filter(s => s.children.length === 0);
|
|
614
|
+
const counts = spans.filter(s => /^[\\d.,]+\\s*(mil|M|K|B)?$/.test(s.textContent?.trim() || '')).map(s => s.textContent?.trim());
|
|
615
|
+
const audioLink = container.querySelector('a[href*="/audio/"], a[href*="/music/"]');
|
|
616
|
+
const captionSpans = spans.filter(s => s.getAttribute('dir') === 'auto' && (s.textContent?.trim().length || 0) > 10);
|
|
617
|
+
const hasFollow = !!Array.from(container.querySelectorAll('*')).find(e => (e.textContent?.trim() === 'Seguir' || e.textContent?.trim() === 'Follow') && e.children.length === 0);
|
|
618
|
+
reels.push({
|
|
619
|
+
username: userLink?.textContent?.trim() || null,
|
|
620
|
+
profileUrl: userLink ? 'https://www.instagram.com' + userLink.getAttribute('href') : null,
|
|
621
|
+
caption: captionSpans[0]?.textContent?.trim()?.substring(0, 200) || null,
|
|
622
|
+
likes: counts[0] || null,
|
|
623
|
+
comments: counts[1] || null,
|
|
624
|
+
audioName: audioLink?.textContent?.trim()?.substring(0, 80) || null,
|
|
625
|
+
audioUrl: audioLink ? 'https://www.instagram.com' + audioLink.getAttribute('href') : null,
|
|
626
|
+
hasFollow,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return reels;
|
|
630
|
+
})()
|
|
631
|
+
`;
|
|
632
|
+
export const browserExtractIgReelsSchema = {
|
|
633
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
634
|
+
};
|
|
635
|
+
export const browserExtractIgReelsDescription = 'Extract visible Instagram Reels with engagement data as structured JSON ' +
|
|
636
|
+
'(username, profileUrl, caption, likes, comments, audioName, audioUrl, hasFollow). ' +
|
|
637
|
+
'Navigate to instagram.com/reels/ first. Scroll down to load more reels.';
|
|
638
|
+
export async function handleBrowserExtractIgReels(args) {
|
|
639
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
640
|
+
if ('error' in resolved)
|
|
641
|
+
return text(resolved.error);
|
|
642
|
+
const result = await ni.evaluate(resolved.sessionId, IG_REELS_EXTRACTOR_SCRIPT);
|
|
643
|
+
if (result.error)
|
|
644
|
+
return text(`Extract failed: ${result.error}`);
|
|
645
|
+
const reels = result.result;
|
|
646
|
+
if (!Array.isArray(reels) || reels.length === 0) {
|
|
647
|
+
return text('No reels found. Navigate to instagram.com/reels/ and scroll down, then retry.');
|
|
648
|
+
}
|
|
649
|
+
return text(`${reels.length} reel(s):\n\n${JSON.stringify(reels, null, 2)}`);
|
|
650
|
+
}
|
|
651
|
+
// ── browser_extract_ig_search ──
|
|
652
|
+
const IG_SEARCH_EXTRACTOR_SCRIPT = `
|
|
653
|
+
Array.from(document.querySelectorAll('main a[href*="/p/"], main a[href*="/reel/"]')).map(a => ({
|
|
654
|
+
url: 'https://www.instagram.com' + a.getAttribute('href'),
|
|
655
|
+
alt: a.querySelector('img')?.getAttribute('alt')?.substring(0, 120) || null,
|
|
656
|
+
isReel: a.getAttribute('href').includes('/reel/'),
|
|
657
|
+
})).filter(p => p.url)
|
|
658
|
+
`;
|
|
659
|
+
export const browserExtractIgSearchSchema = {
|
|
660
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
661
|
+
};
|
|
662
|
+
export const browserExtractIgSearchDescription = 'Extract posts from the Instagram Explore page as structured JSON (url, alt, isReel). ' +
|
|
663
|
+
'Navigate to instagram.com/explore/ first. Scroll to load more content. ' +
|
|
664
|
+
'For hashtag search, navigate to instagram.com/explore/tags/HASHTAG/.';
|
|
665
|
+
export async function handleBrowserExtractIgSearch(args) {
|
|
666
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
667
|
+
if ('error' in resolved)
|
|
668
|
+
return text(resolved.error);
|
|
669
|
+
const result = await ni.evaluate(resolved.sessionId, IG_SEARCH_EXTRACTOR_SCRIPT);
|
|
670
|
+
if (result.error)
|
|
671
|
+
return text(`Extract failed: ${result.error}`);
|
|
672
|
+
const posts = result.result;
|
|
673
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
674
|
+
return text('No explore posts found. Navigate to instagram.com/explore/ and scroll down, then retry.');
|
|
675
|
+
}
|
|
676
|
+
return text(`${posts.length} post(s):\n\n${JSON.stringify(posts, null, 2)}`);
|
|
677
|
+
}
|
|
678
|
+
// ── LinkedIn Extraction Tools ──
|
|
679
|
+
// ── browser_extract_li_feed ──
|
|
680
|
+
const LI_FEED_EXTRACTOR_SCRIPT = `
|
|
681
|
+
Array.from(document.querySelectorAll('.feed-shared-update-v2[data-urn*="urn:li:activity"]')).map(post => {
|
|
682
|
+
const actorLink = post.querySelector('.update-components-actor__meta-link') || post.querySelector('.update-components-actor__name a');
|
|
683
|
+
const actorUrl = actorLink?.href?.split('?')[0] || null;
|
|
684
|
+
const nameSpan = actorLink?.querySelector('.update-components-actor__title span[aria-hidden="true"]') || actorLink?.querySelector('span[aria-hidden="true"]');
|
|
685
|
+
const actorName = nameSpan?.textContent?.trim() || null;
|
|
686
|
+
const descSpan = post.querySelector('.update-components-actor__description span[aria-hidden="true"]');
|
|
687
|
+
const headline = descSpan?.textContent?.trim() || null;
|
|
688
|
+
const subDesc = post.querySelector('.update-components-actor__sub-description span[aria-hidden="true"]')?.textContent?.trim() || null;
|
|
689
|
+
const timeMatch = subDesc?.match(/^(\\d+\\s*\\w+)/) || null;
|
|
690
|
+
const time = timeMatch ? timeMatch[1] : (subDesc || null);
|
|
691
|
+
const isPromoted = /Promovido|Promoted/i.test(subDesc || '');
|
|
692
|
+
const textEl = post.querySelector('.feed-shared-update-v2__description, .update-components-text');
|
|
693
|
+
const text = textEl?.innerText?.trim() || null;
|
|
694
|
+
const reactionsBtn = post.querySelector('.social-details-social-counts__reactions-count');
|
|
695
|
+
const reactions = reactionsBtn?.textContent?.trim() || null;
|
|
696
|
+
const commentsEl = post.querySelector('.social-details-social-counts__comments');
|
|
697
|
+
const commentsCount = commentsEl?.textContent?.trim()?.match(/\\d[\\d.,]*/)?.[0] || null;
|
|
698
|
+
const hasImage = !!post.querySelector('.update-components-image img');
|
|
699
|
+
const hasVideo = !!post.querySelector('video, .update-components-linkedin-video');
|
|
700
|
+
const hasArticle = !!post.querySelector('.update-components-article');
|
|
701
|
+
const hasDocument = !!post.querySelector('.update-components-linkedin-document');
|
|
702
|
+
const mediaType = hasVideo ? 'video' : hasDocument ? 'document' : hasArticle ? 'article' : hasImage ? 'image' : 'text';
|
|
703
|
+
const articleTitle = post.querySelector('.update-components-article__title')?.textContent?.trim() || null;
|
|
704
|
+
const articleLink = post.querySelector('.update-components-article a')?.href?.split('?')[0] || null;
|
|
705
|
+
const reshareActor = post.querySelector('.update-components-mini-update-v2');
|
|
706
|
+
const isRepost = !!reshareActor;
|
|
707
|
+
const postUrn = post.getAttribute('data-urn');
|
|
708
|
+
const postUrl = postUrn ? 'https://www.linkedin.com/feed/update/' + postUrn + '/' : null;
|
|
709
|
+
return { author: actorName, authorUrl: actorUrl, headline, time, text, reactions, commentsCount, mediaType, articleTitle, articleLink, isPromoted, isRepost, postUrl };
|
|
710
|
+
}).filter(p => p.author || p.text)
|
|
711
|
+
`.trim();
|
|
712
|
+
export const browserExtractLiFeedSchema = {
|
|
713
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
714
|
+
};
|
|
715
|
+
export const browserExtractLiFeedDescription = 'Extract all currently visible LinkedIn feed posts as structured JSON ' +
|
|
716
|
+
'(author, authorUrl, headline, time, text, reactions, commentsCount, mediaType, articleTitle, articleLink, isPromoted, isRepost, postUrl). ' +
|
|
717
|
+
'Navigate to linkedin.com/feed/ first. Scroll down with browser_scroll to load more posts. ' +
|
|
718
|
+
'Much faster than browser_snapshot for data harvesting from the LinkedIn feed.';
|
|
719
|
+
export async function handleBrowserExtractLiFeed(args) {
|
|
720
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
721
|
+
if ('error' in resolved)
|
|
722
|
+
return text(resolved.error);
|
|
723
|
+
const result = await ni.evaluate(resolved.sessionId, LI_FEED_EXTRACTOR_SCRIPT);
|
|
724
|
+
if (result.error)
|
|
725
|
+
return text(`Extract failed: ${result.error}`);
|
|
726
|
+
const posts = result.result;
|
|
727
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
728
|
+
return text('No LinkedIn feed posts found. Navigate to linkedin.com/feed/ and scroll down, then retry.');
|
|
729
|
+
}
|
|
730
|
+
return text(`${posts.length} post(s):\n\n${JSON.stringify(posts, null, 2)}`);
|
|
731
|
+
}
|
|
732
|
+
// ── browser_extract_li_profile ──
|
|
733
|
+
const LI_PROFILE_EXTRACTOR_SCRIPT = `
|
|
734
|
+
(() => {
|
|
735
|
+
const name = document.querySelector('.text-heading-xlarge, h1')?.textContent?.trim() || null;
|
|
736
|
+
const headline = document.querySelector('.text-body-medium.break-words')?.textContent?.trim() || null;
|
|
737
|
+
const location = document.querySelector('.text-body-small.inline.t-black--light.break-words')?.textContent?.trim() || null;
|
|
738
|
+
const profilePic = document.querySelector('.pv-top-card-profile-picture__image, img.profile-photo-edit__preview')?.src || null;
|
|
739
|
+
const connectionsEl = Array.from(document.querySelectorAll('span')).find(s => /conexõ|connection/i.test(s.textContent || ''));
|
|
740
|
+
const connections = connectionsEl?.textContent?.trim() || null;
|
|
741
|
+
const aboutSection = document.querySelector('.pv-about-section .inline-show-more-text, section .pv-shared-text-with-see-more span[aria-hidden="true"]');
|
|
742
|
+
const about = aboutSection?.textContent?.trim() || null;
|
|
743
|
+
const sections = Array.from(document.querySelectorAll('section.artdeco-card')).map(s => {
|
|
744
|
+
const heading = (s.querySelector('.pvs-header__title span[aria-hidden="true"]') || s.querySelector('h2 span[aria-hidden="true"]') || s.querySelector('h2'))?.textContent?.trim()?.replace(/\\s+/g, ' ');
|
|
745
|
+
if (!heading) return null;
|
|
746
|
+
const items = Array.from(s.querySelectorAll('li.artdeco-list__item, li.pvs-list__paged-list-item')).map(li => {
|
|
747
|
+
const title = li.querySelector('.t-bold span[aria-hidden="true"], .mr1.t-bold span')?.textContent?.trim();
|
|
748
|
+
const subtitle = li.querySelector('.t-normal span[aria-hidden="true"], .t-14.t-normal span')?.textContent?.trim();
|
|
749
|
+
const meta = li.querySelector('.pvs-entity__caption-wrapper span[aria-hidden="true"]')?.textContent?.trim();
|
|
750
|
+
return { title: title || null, subtitle: subtitle || null, meta: meta || null };
|
|
751
|
+
}).filter(i => i.title);
|
|
752
|
+
return { heading, items };
|
|
753
|
+
}).filter(Boolean);
|
|
754
|
+
const activityPosts = Array.from(document.querySelectorAll('.pv-recent-activity-section li, section .feed-shared-update-v2')).slice(0, 5).map(p => ({
|
|
755
|
+
text: p.querySelector('.feed-shared-update-v2__description, .update-components-text')?.innerText?.trim()?.substring(0, 200) || p.innerText?.trim()?.substring(0, 200),
|
|
756
|
+
}));
|
|
757
|
+
return { name, headline, location, profilePic, connections, about, sections, recentActivity: activityPosts };
|
|
758
|
+
})()
|
|
759
|
+
`.trim();
|
|
760
|
+
export const browserExtractLiProfileSchema = {
|
|
761
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
762
|
+
};
|
|
763
|
+
export const browserExtractLiProfileDescription = 'Extract LinkedIn profile data as structured JSON ' +
|
|
764
|
+
'(name, headline, location, profilePic, connections, about, sections[{heading, items[{title, subtitle, meta}]}], recentActivity). ' +
|
|
765
|
+
'Navigate to linkedin.com/in/USERNAME/ first. Sections include Experience, Education, Skills, etc. ' +
|
|
766
|
+
'Use linkedin.com/in/me/ for the logged-in user\'s own profile.';
|
|
767
|
+
export async function handleBrowserExtractLiProfile(args) {
|
|
768
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
769
|
+
if ('error' in resolved)
|
|
770
|
+
return text(resolved.error);
|
|
771
|
+
const result = await ni.evaluate(resolved.sessionId, LI_PROFILE_EXTRACTOR_SCRIPT);
|
|
772
|
+
if (result.error)
|
|
773
|
+
return text(`Extract failed: ${result.error}`);
|
|
774
|
+
const profile = result.result;
|
|
775
|
+
if (!profile || !profile.name) {
|
|
776
|
+
return text('No profile data found. Navigate to linkedin.com/in/USERNAME/ first, then retry.');
|
|
777
|
+
}
|
|
778
|
+
return text(`Profile:\n\n${JSON.stringify(profile, null, 2)}`);
|
|
779
|
+
}
|
|
780
|
+
// ── browser_extract_li_post ──
|
|
781
|
+
const LI_POST_EXTRACTOR_SCRIPT = `
|
|
782
|
+
(() => {
|
|
783
|
+
const post = document.querySelector('.feed-shared-update-v2[data-urn]');
|
|
784
|
+
if (!post) return null;
|
|
785
|
+
const actorLink = post.querySelector('.update-components-actor__meta-link') || post.querySelector('.update-components-actor__name a');
|
|
786
|
+
const nameSpan = actorLink?.querySelector('.update-components-actor__title span[aria-hidden="true"]') || actorLink?.querySelector('span[aria-hidden="true"]');
|
|
787
|
+
const author = nameSpan?.textContent?.trim() || null;
|
|
788
|
+
const authorUrl = actorLink?.href?.split('?')[0] || null;
|
|
789
|
+
const headline = post.querySelector('.update-components-actor__description span[aria-hidden="true"]')?.textContent?.trim() || null;
|
|
790
|
+
const subDesc = post.querySelector('.update-components-actor__sub-description span[aria-hidden="true"]')?.textContent?.trim() || null;
|
|
791
|
+
const text = post.querySelector('.feed-shared-update-v2__description, .update-components-text')?.innerText?.trim() || null;
|
|
792
|
+
const reactions = post.querySelector('.social-details-social-counts__reactions-count')?.textContent?.trim() || null;
|
|
793
|
+
const commentsCountEl = post.querySelector('.social-details-social-counts__comments');
|
|
794
|
+
const commentsCount = commentsCountEl?.textContent?.trim()?.match(/\\d[\\d.,]*/)?.[0] || null;
|
|
795
|
+
const postUrn = post.getAttribute('data-urn');
|
|
796
|
+
const comments = Array.from(document.querySelectorAll('.comments-comment-item, article.comments-comment-entity')).map(c => {
|
|
797
|
+
const cAuthorLink = c.querySelector('a[href*="/in/"]');
|
|
798
|
+
const cName = c.querySelector('.comments-post-meta__name-text span[aria-hidden="true"]')?.textContent?.trim() ||
|
|
799
|
+
cAuthorLink?.textContent?.trim()?.replace(/\\s+/g, ' ') || null;
|
|
800
|
+
const cUrl = cAuthorLink?.href?.split('?')[0] || null;
|
|
801
|
+
const cText = c.querySelector('.comments-comment-item__main-content, .comments-comment-item-content-body, .feed-shared-inline-show-more-text')?.innerText?.trim() || null;
|
|
802
|
+
const cTime = c.querySelector('time')?.textContent?.trim() || c.querySelector('.comments-comment-item__timestamp')?.textContent?.trim() || null;
|
|
803
|
+
const cLikes = c.querySelector('.comments-comment-social-bar__reactions-count, .social-details-social-counts__reactions-count')?.textContent?.trim() || null;
|
|
804
|
+
return { author: cName, authorUrl: cUrl, text: cText, time: cTime, likes: cLikes };
|
|
805
|
+
}).filter(c => c.text);
|
|
806
|
+
return { author, authorUrl, headline, time: subDesc, text, reactions, commentsCount, postUrn, comments };
|
|
807
|
+
})()
|
|
808
|
+
`.trim();
|
|
809
|
+
export const browserExtractLiPostSchema = {
|
|
810
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
811
|
+
};
|
|
812
|
+
export const browserExtractLiPostDescription = 'Extract a single LinkedIn post with comments as structured JSON ' +
|
|
813
|
+
'(author, authorUrl, headline, time, text, reactions, commentsCount, postUrn, comments[{author, authorUrl, text, time, likes}]). ' +
|
|
814
|
+
'Navigate to linkedin.com/feed/update/urn:li:activity:ID/ first. ' +
|
|
815
|
+
'Click "Comentar" button to expand comment section, then scroll to load more comments before extracting.';
|
|
816
|
+
export async function handleBrowserExtractLiPost(args) {
|
|
817
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
818
|
+
if ('error' in resolved)
|
|
819
|
+
return text(resolved.error);
|
|
820
|
+
const result = await ni.evaluate(resolved.sessionId, LI_POST_EXTRACTOR_SCRIPT);
|
|
821
|
+
if (result.error)
|
|
822
|
+
return text(`Extract failed: ${result.error}`);
|
|
823
|
+
const post = result.result;
|
|
824
|
+
if (!post || !post.author) {
|
|
825
|
+
return text('No post data found. Navigate to linkedin.com/feed/update/urn:li:activity:ID/ first, then retry.');
|
|
826
|
+
}
|
|
827
|
+
return text(`Post:\n\n${JSON.stringify(post, null, 2)}`);
|
|
828
|
+
}
|
|
829
|
+
// ── browser_extract_li_notifications ──
|
|
830
|
+
const LI_NOTIFICATIONS_EXTRACTOR_SCRIPT = `
|
|
831
|
+
Array.from(document.querySelectorAll('article.nt-card')).map(card => {
|
|
832
|
+
const isUnread = card.classList.contains('nt-card--unread');
|
|
833
|
+
const link = card.querySelector('a[href*="linkedin.com"]');
|
|
834
|
+
const url = link?.href?.split('&or')[0] || link?.href || null;
|
|
835
|
+
const allText = card.innerText?.trim()?.replace(/\\s+/g, ' ') || null;
|
|
836
|
+
const cleanText = allText?.replace(/^(Notificação não lida\\.\\s*|O status está off-line\\s*)/, '')?.replace(/\\s*há\\s*\\d+.*$/, '')?.trim() || allText;
|
|
837
|
+
const timeEl = Array.from(card.querySelectorAll('span, p')).find(s => /^\\d+\\s*(min|h|d|sem|s|m|dia|hour|day|week|month|mo)/i.test(s.textContent?.trim() || ''));
|
|
838
|
+
const time = timeEl?.textContent?.trim() || null;
|
|
839
|
+
const img = card.querySelector('img')?.src || null;
|
|
840
|
+
return { text: cleanText?.substring(0, 300), time, isUnread, url, image: img };
|
|
841
|
+
}).filter(n => n.text && !n.text.includes('funciona melhor no novo aplicativo'))
|
|
842
|
+
`.trim();
|
|
843
|
+
export const browserExtractLiNotificationsSchema = {
|
|
844
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
845
|
+
};
|
|
846
|
+
export const browserExtractLiNotificationsDescription = 'Extract LinkedIn notifications as structured JSON (text, time, isUnread, url, image). ' +
|
|
847
|
+
'Navigate to linkedin.com/notifications/ first. Scroll down to load older notifications. ' +
|
|
848
|
+
'Useful for monitoring engagement, connection requests, and mentions.';
|
|
849
|
+
export async function handleBrowserExtractLiNotifications(args) {
|
|
850
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
851
|
+
if ('error' in resolved)
|
|
852
|
+
return text(resolved.error);
|
|
853
|
+
const result = await ni.evaluate(resolved.sessionId, LI_NOTIFICATIONS_EXTRACTOR_SCRIPT);
|
|
854
|
+
if (result.error)
|
|
855
|
+
return text(`Extract failed: ${result.error}`);
|
|
856
|
+
const notifs = result.result;
|
|
857
|
+
if (!Array.isArray(notifs) || notifs.length === 0) {
|
|
858
|
+
return text('No notifications found. Navigate to linkedin.com/notifications/ first, then retry.');
|
|
859
|
+
}
|
|
860
|
+
return text(`${notifs.length} notification(s):\n\n${JSON.stringify(notifs, null, 2)}`);
|
|
861
|
+
}
|
|
862
|
+
// ── browser_extract_li_messages ──
|
|
863
|
+
const LI_MESSAGES_EXTRACTOR_SCRIPT = `
|
|
864
|
+
(() => {
|
|
865
|
+
const results = { conversations: [], activeThread: [] };
|
|
866
|
+
results.conversations = Array.from(document.querySelectorAll('.msg-conversation-listitem')).map(c => {
|
|
867
|
+
const name = c.querySelector('.msg-conversation-listitem__participant-names, .msg-conversation-card__participant-names')?.textContent?.trim() || null;
|
|
868
|
+
const snippet = c.querySelector('.msg-conversation-listitem__message-snippet, .msg-conversation-card__message-snippet')?.textContent?.trim()?.substring(0, 150) || null;
|
|
869
|
+
const time = c.querySelector('.msg-conversation-listitem__time-stamp, .msg-conversation-card__time-stamp, time')?.textContent?.trim() || null;
|
|
870
|
+
const isUnread = c.classList.contains('msg-conversation-listitem--unread');
|
|
871
|
+
const link = c.querySelector('a')?.href || null;
|
|
872
|
+
return { name, lastMessage: snippet, time, isUnread, url: link };
|
|
873
|
+
}).filter(c => c.name);
|
|
874
|
+
results.activeThread = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem')).map(m => {
|
|
875
|
+
const sender = m.querySelector('.msg-s-message-group__name, .msg-s-event-listitem__link')?.textContent?.trim() || null;
|
|
876
|
+
const text = m.querySelector('.msg-s-event-listitem__body, .msg-s-event-listitem__message-bubble')?.textContent?.trim()?.substring(0, 500) || null;
|
|
877
|
+
const time = m.querySelector('.msg-s-message-group__timestamp, time')?.textContent?.trim() || null;
|
|
878
|
+
const isIncoming = !m.classList.contains('msg-s-message-list__event--outgoing');
|
|
879
|
+
return { sender, text, time, direction: isIncoming ? 'in' : 'out' };
|
|
880
|
+
}).filter(m => m.text);
|
|
881
|
+
return results;
|
|
882
|
+
})()
|
|
883
|
+
`.trim();
|
|
884
|
+
export const browserExtractLiMessagesSchema = {
|
|
885
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
886
|
+
};
|
|
887
|
+
export const browserExtractLiMessagesDescription = 'Extract LinkedIn messaging data as structured JSON with two arrays: ' +
|
|
888
|
+
'conversations[{name, lastMessage, time, isUnread, url}] (sidebar list) and ' +
|
|
889
|
+
'activeThread[{sender, text, time, direction}] (open conversation messages). ' +
|
|
890
|
+
'Navigate to linkedin.com/messaging/ first. Click a conversation to load its messages. ' +
|
|
891
|
+
'To send a message: browser_fill on .msg-form__contenteditable then browser_click on .msg-form__send-button.';
|
|
892
|
+
export async function handleBrowserExtractLiMessages(args) {
|
|
893
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
894
|
+
if ('error' in resolved)
|
|
895
|
+
return text(resolved.error);
|
|
896
|
+
const result = await ni.evaluate(resolved.sessionId, LI_MESSAGES_EXTRACTOR_SCRIPT);
|
|
897
|
+
if (result.error)
|
|
898
|
+
return text(`Extract failed: ${result.error}`);
|
|
899
|
+
const data = result.result;
|
|
900
|
+
const convos = data?.conversations || [];
|
|
901
|
+
const msgs = data?.activeThread || [];
|
|
902
|
+
if (convos.length === 0 && msgs.length === 0) {
|
|
903
|
+
return text('No messages found. Navigate to linkedin.com/messaging/ first, then retry.');
|
|
904
|
+
}
|
|
905
|
+
return text(`${convos.length} conversation(s), ${msgs.length} message(s) in active thread:\n\n${JSON.stringify(data, null, 2)}`);
|
|
906
|
+
}
|
|
907
|
+
// ── browser_extract_li_search_people ──
|
|
908
|
+
const LI_SEARCH_PEOPLE_EXTRACTOR_SCRIPT = `
|
|
909
|
+
(() => {
|
|
910
|
+
const main = document.querySelector('main') || document.body;
|
|
911
|
+
const profileLinks = Array.from(main.querySelectorAll('a[href*="/in/"]')).filter(a => {
|
|
912
|
+
const href = a.getAttribute('href') || '';
|
|
913
|
+
return href.match(/\\/in\\/[^/]+\\/?$/) && !href.includes('/search/') && a.textContent?.trim()?.length > 2;
|
|
914
|
+
});
|
|
915
|
+
const seen = new Set();
|
|
916
|
+
return profileLinks.map(a => {
|
|
917
|
+
const href = a.href?.split('?')[0];
|
|
918
|
+
if (seen.has(href)) return null;
|
|
919
|
+
seen.add(href);
|
|
920
|
+
const card = a.parentElement;
|
|
921
|
+
if (!card) return null;
|
|
922
|
+
const pEls = Array.from(card.querySelectorAll('p'));
|
|
923
|
+
const nameText = a.textContent?.trim()?.replace(/\\s+/g, ' ')?.replace(/\\s*•.*/, '') || null;
|
|
924
|
+
const headline = pEls[1]?.textContent?.trim() || null;
|
|
925
|
+
const location = pEls[2]?.textContent?.trim() || null;
|
|
926
|
+
const currentRole = pEls.find(p => /^(Atual|Current)/i.test(p.textContent?.trim() || ''))?.textContent?.trim() || null;
|
|
927
|
+
const followers = pEls.find(p => /seguidores|followers/i.test(p.textContent?.trim() || ''))?.textContent?.trim() || null;
|
|
928
|
+
const mutual = pEls.find(p => /em comum|mutual/i.test(p.textContent?.trim() || ''))?.textContent?.trim() || null;
|
|
929
|
+
const connectBtn = card.querySelector('button');
|
|
930
|
+
const btnText = connectBtn?.textContent?.trim();
|
|
931
|
+
const img = card.querySelector('img')?.src || null;
|
|
932
|
+
return {
|
|
933
|
+
name: nameText?.substring(0, 80),
|
|
934
|
+
profileUrl: href,
|
|
935
|
+
headline: headline?.substring(0, 200) || null,
|
|
936
|
+
location: location?.substring(0, 80) || null,
|
|
937
|
+
currentRole: currentRole?.substring(0, 150) || null,
|
|
938
|
+
followers: followers?.substring(0, 40) || null,
|
|
939
|
+
mutual: mutual?.substring(0, 80) || null,
|
|
940
|
+
actionButton: (btnText && btnText.length < 30) ? btnText : null,
|
|
941
|
+
image: img,
|
|
942
|
+
};
|
|
943
|
+
}).filter(Boolean);
|
|
944
|
+
})()
|
|
945
|
+
`.trim();
|
|
946
|
+
export const browserExtractLiSearchPeopleSchema = {
|
|
947
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
948
|
+
};
|
|
949
|
+
export const browserExtractLiSearchPeopleDescription = 'Extract LinkedIn people search results as structured JSON ' +
|
|
950
|
+
'(name, profileUrl, headline, location, mutual, actionButton, image). ' +
|
|
951
|
+
'Navigate to linkedin.com/search/results/people/?keywords=QUERY first. ' +
|
|
952
|
+
'Scroll down to load more results. Also works on linkedin.com/search/results/all/.';
|
|
953
|
+
export async function handleBrowserExtractLiSearchPeople(args) {
|
|
954
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
955
|
+
if ('error' in resolved)
|
|
956
|
+
return text(resolved.error);
|
|
957
|
+
const result = await ni.evaluate(resolved.sessionId, LI_SEARCH_PEOPLE_EXTRACTOR_SCRIPT);
|
|
958
|
+
if (result.error)
|
|
959
|
+
return text(`Extract failed: ${result.error}`);
|
|
960
|
+
const people = result.result;
|
|
961
|
+
if (!Array.isArray(people) || people.length === 0) {
|
|
962
|
+
return text('No search results found. Navigate to linkedin.com/search/results/people/?keywords=QUERY first, then retry.');
|
|
963
|
+
}
|
|
964
|
+
return text(`${people.length} result(s):\n\n${JSON.stringify(people, null, 2)}`);
|
|
965
|
+
}
|
|
966
|
+
// ── browser_extract_li_network ──
|
|
967
|
+
const LI_NETWORK_EXTRACTOR_SCRIPT = `
|
|
968
|
+
(() => {
|
|
969
|
+
const results = { invitations: [], suggestions: [] };
|
|
970
|
+
const invCards = document.querySelectorAll('.invitation-card, [data-view-name="invitation-card"]');
|
|
971
|
+
results.invitations = Array.from(invCards).map(inv => {
|
|
972
|
+
const name = inv.querySelector('.invitation-card__name, span[aria-hidden="true"]')?.textContent?.trim() || null;
|
|
973
|
+
const subtitle = inv.querySelector('.invitation-card__subtitle, .invitation-card__occupation')?.textContent?.trim() || null;
|
|
974
|
+
const acceptBtn = inv.querySelector('button[aria-label*="Aceitar"], button[aria-label*="Accept"]');
|
|
975
|
+
const ignoreBtn = inv.querySelector('button[aria-label*="Ignorar"], button[aria-label*="Ignore"]');
|
|
976
|
+
const profileLink = inv.querySelector('a[href*="/in/"]')?.href?.split('?')[0] || null;
|
|
977
|
+
return { name, subtitle, profileUrl: profileLink, acceptLabel: acceptBtn?.getAttribute('aria-label') || null, ignoreLabel: ignoreBtn?.getAttribute('aria-label') || null };
|
|
978
|
+
}).filter(i => i.name);
|
|
979
|
+
const followBtns = Array.from(document.querySelectorAll('main button')).filter(b => {
|
|
980
|
+
const t = b.textContent?.trim();
|
|
981
|
+
return t === 'Seguir' || t === '+ Seguir' || t === 'Follow' || t === '+ Follow' || t === 'Conectar' || t === 'Connect';
|
|
982
|
+
});
|
|
983
|
+
const seen = new Set();
|
|
984
|
+
results.suggestions = followBtns.map(btn => {
|
|
985
|
+
let card = btn.parentElement;
|
|
986
|
+
for (let i = 0; i < 8; i++) {
|
|
987
|
+
if (!card) break;
|
|
988
|
+
if (card.querySelector('img') && card.querySelector('a[href*="/in/"]')) break;
|
|
989
|
+
card = card.parentElement;
|
|
990
|
+
}
|
|
991
|
+
if (!card) return null;
|
|
992
|
+
const profileLink = card.querySelector('a[href*="/in/"]');
|
|
993
|
+
const url = profileLink?.href?.split('?')[0];
|
|
994
|
+
if (!url || seen.has(url)) return null;
|
|
995
|
+
seen.add(url);
|
|
996
|
+
const allText = card.innerText?.trim()?.split('\\n').filter(t => t.trim().length > 1) || [];
|
|
997
|
+
const name = allText[0]?.trim() || null;
|
|
998
|
+
const nameClean = name?.replace(/[,\\s]*(Top Voice|Premium|Verificado|Verified).*$/i, '')?.trim();
|
|
999
|
+
const subtitle = allText.find(t => {
|
|
1000
|
+
const clean = t.trim();
|
|
1001
|
+
return clean !== name && clean !== nameClean && !clean.includes('Seguir') && !clean.includes('Follow') && !clean.includes('Conectar') && !clean.includes('Connect') && !/^\\d/.test(clean) && clean.length > 5;
|
|
1002
|
+
})?.trim() || null;
|
|
1003
|
+
const followersText = allText.find(t => /seguidores|followers/i.test(t))?.trim() || null;
|
|
1004
|
+
const img = card.querySelector('img')?.src || null;
|
|
1005
|
+
return { name, subtitle, followers: followersText, profileUrl: url, action: btn.textContent?.trim(), image: img };
|
|
1006
|
+
}).filter(Boolean);
|
|
1007
|
+
return results;
|
|
1008
|
+
})()
|
|
1009
|
+
`.trim();
|
|
1010
|
+
export const browserExtractLiNetworkSchema = {
|
|
1011
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
1012
|
+
};
|
|
1013
|
+
export const browserExtractLiNetworkDescription = 'Extract LinkedIn My Network data as structured JSON with two arrays: ' +
|
|
1014
|
+
'invitations[{name, subtitle, profileUrl, acceptLabel, ignoreLabel}] (pending connection requests) and ' +
|
|
1015
|
+
'suggestions[{name, subtitle, followers, profileUrl, action, image}] (people/creators to follow or connect). ' +
|
|
1016
|
+
'Navigate to linkedin.com/mynetwork/ first. Scroll down to load more suggestions. ' +
|
|
1017
|
+
'To accept an invitation: browser_click on button with the acceptLabel. ' +
|
|
1018
|
+
'To connect: browser_click on the respective Conectar/Connect button.';
|
|
1019
|
+
export async function handleBrowserExtractLiNetwork(args) {
|
|
1020
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
1021
|
+
if ('error' in resolved)
|
|
1022
|
+
return text(resolved.error);
|
|
1023
|
+
const result = await ni.evaluate(resolved.sessionId, LI_NETWORK_EXTRACTOR_SCRIPT);
|
|
1024
|
+
if (result.error)
|
|
1025
|
+
return text(`Extract failed: ${result.error}`);
|
|
1026
|
+
const data = result.result;
|
|
1027
|
+
const invitations = data?.invitations || [];
|
|
1028
|
+
const suggestions = data?.suggestions || [];
|
|
1029
|
+
if (invitations.length === 0 && suggestions.length === 0) {
|
|
1030
|
+
return text('No network data found. Navigate to linkedin.com/mynetwork/ first, then retry.');
|
|
1031
|
+
}
|
|
1032
|
+
return text(`${invitations.length} invitation(s), ${suggestions.length} suggestion(s):\n\n${JSON.stringify(data, null, 2)}`);
|
|
1033
|
+
}
|
|
1034
|
+
// ── browser_extract_li_jobs ──
|
|
1035
|
+
const LI_JOBS_EXTRACTOR_SCRIPT = `
|
|
1036
|
+
(() => {
|
|
1037
|
+
const jobs = [];
|
|
1038
|
+
const seen = new Set();
|
|
1039
|
+
|
|
1040
|
+
// --- Search results page (/jobs/search/) ---
|
|
1041
|
+
const searchCards = document.querySelectorAll('[data-occludable-job-id]');
|
|
1042
|
+
searchCards.forEach(card => {
|
|
1043
|
+
const jobId = card.getAttribute('data-occludable-job-id');
|
|
1044
|
+
if (seen.has(jobId)) return;
|
|
1045
|
+
seen.add(jobId);
|
|
1046
|
+
|
|
1047
|
+
const titleLink = card.querySelector('a[href*="/jobs/view/"]');
|
|
1048
|
+
const title = titleLink?.querySelector('span[aria-hidden="true"]')?.textContent?.trim()
|
|
1049
|
+
|| titleLink?.querySelector('strong')?.textContent?.trim() || null;
|
|
1050
|
+
const jobUrl = titleLink?.href?.split('?')[0] || null;
|
|
1051
|
+
|
|
1052
|
+
const company = card.querySelector('.artdeco-entity-lockup__subtitle')?.textContent?.trim()?.replace(/\\s+/g, ' ') || null;
|
|
1053
|
+
const companyLink = card.querySelector('a[href*="/company/"]');
|
|
1054
|
+
const companyUrl = companyLink?.href?.split('?')[0] || null;
|
|
1055
|
+
|
|
1056
|
+
const location = card.querySelector('.artdeco-entity-lockup__caption')?.textContent?.trim()?.replace(/\\s+/g, ' ') || null;
|
|
1057
|
+
|
|
1058
|
+
const logo = card.querySelector('img')?.src || null;
|
|
1059
|
+
const footerText = card.querySelector('.job-card-container__footer-wrapper')?.textContent?.trim()?.replace(/\\s+/g, ' ') || '';
|
|
1060
|
+
const promoted = /Promovida|Promoted/i.test(footerText);
|
|
1061
|
+
const easyApply = /Candidatura simplificada|Easy Apply/i.test(footerText);
|
|
1062
|
+
|
|
1063
|
+
// Salary: sometimes in metadata items
|
|
1064
|
+
const allText = card.innerText || '';
|
|
1065
|
+
const salaryMatch = allText.match(/(?:R\\$|US\\$|\\$)[\\s\\d.,\\/]+(?:por\\s+hr|por\\s+m[eê]s|per\\s+hr|per\\s+month|yr|hr|k)?(?:\\s*-\\s*(?:R\\$|US\\$|\\$)?[\\s\\d.,\\/]+(?:por\\s+hr|por\\s+m[eê]s|per\\s+hr|per\\s+month|yr|hr|k)?)?/i);
|
|
1066
|
+
const salary = salaryMatch ? salaryMatch[0].trim() : null;
|
|
1067
|
+
|
|
1068
|
+
if (title) jobs.push({ jobId, title, company, companyUrl, location, salary, jobUrl, logo, promoted, easyApply, postedDate: null });
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// --- Homepage (/jobs/) — recommended/top-applicant/easy-apply cards ---
|
|
1072
|
+
if (jobs.length === 0) {
|
|
1073
|
+
const homeLinks = document.querySelectorAll('main a[href*="/jobs/collections/"], main a[href*="/jobs/view/"]');
|
|
1074
|
+
homeLinks.forEach(a => {
|
|
1075
|
+
const href = a.getAttribute('href') || '';
|
|
1076
|
+
if (!href.includes('/jobs/collections/') && !href.includes('/jobs/view/')) return;
|
|
1077
|
+
const pEls = Array.from(a.querySelectorAll('p'));
|
|
1078
|
+
if (pEls.length < 2) return; // skip "View all" links
|
|
1079
|
+
|
|
1080
|
+
// Extract jobId from URL if present
|
|
1081
|
+
const idMatch = href.match(/currentJobId=(\\d+)/) || href.match(/\\/jobs\\/view\\/(\\d+)/);
|
|
1082
|
+
const jobId = idMatch ? idMatch[1] : null;
|
|
1083
|
+
if (jobId && seen.has(jobId)) return;
|
|
1084
|
+
if (jobId) seen.add(jobId);
|
|
1085
|
+
|
|
1086
|
+
// Title: first p, strip "(Vaga verificada)" suffix and duplicated text
|
|
1087
|
+
const rawTitle = pEls[0]?.textContent?.trim() || '';
|
|
1088
|
+
const title = rawTitle
|
|
1089
|
+
.replace(/\\s*\\(Vaga verificada\\).*/i, '')
|
|
1090
|
+
.replace(/\\s*\\(Verified listing\\).*/i, '')
|
|
1091
|
+
.trim() || null;
|
|
1092
|
+
if (!title) return;
|
|
1093
|
+
|
|
1094
|
+
// Parse p elements: filter out dots and noise
|
|
1095
|
+
const texts = pEls.map(p => p.textContent?.trim()).filter(t => t && t !== '\\u2022' && t !== '\\u00b7' && t.length > 1);
|
|
1096
|
+
let company = null;
|
|
1097
|
+
let locationVal = null;
|
|
1098
|
+
let salary = null;
|
|
1099
|
+
let postedDate = null;
|
|
1100
|
+
const promoted = /Promovida|Promoted/i.test(a.innerText || '');
|
|
1101
|
+
const easyApply = /Candidatura simplificada|Easy Apply/i.test(a.innerText || '');
|
|
1102
|
+
|
|
1103
|
+
if (texts.length >= 2) company = texts[1];
|
|
1104
|
+
if (texts.length >= 3) locationVal = texts[2];
|
|
1105
|
+
|
|
1106
|
+
// Find salary in any p
|
|
1107
|
+
for (const t of texts) {
|
|
1108
|
+
const sm = t.match(/(?:R\\$|US\\$|\\$)[\\s\\d.,\\/]+(?:por\\s+hr|por\\s+m[eê]s|per\\s+hr|per\\s+month|yr|hr|k)?(?:\\s*-\\s*(?:R\\$|US\\$|\\$)?[\\s\\d.,\\/]+(?:por\\s+hr|por\\s+m[eê]s|per\\s+hr|per\\s+month|yr|hr|k)?)?/i);
|
|
1109
|
+
if (sm) { salary = sm[0].trim(); break; }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Find posted date in any p
|
|
1113
|
+
for (const t of texts) {
|
|
1114
|
+
const dm = t.match(/(?:Anunciada\\s+h[aá]|Posted)\\s+.+/i);
|
|
1115
|
+
if (dm) { postedDate = dm[0].trim(); break; }
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Remove noise from company/location
|
|
1119
|
+
if (/^(Promovida|Promoted|Candidatura|Easy Apply)$/i.test(company || '')) company = null;
|
|
1120
|
+
if (/^(Promovida|Promoted|Candidatura|Easy Apply)$/i.test(locationVal || '')) locationVal = null;
|
|
1121
|
+
|
|
1122
|
+
const jobUrl = jobId ? 'https://www.linkedin.com/jobs/view/' + jobId + '/' : null;
|
|
1123
|
+
const logo = a.querySelector('img')?.src || null;
|
|
1124
|
+
|
|
1125
|
+
jobs.push({ jobId, title, company, companyUrl: null, location: locationVal, salary, jobUrl, logo, promoted, easyApply, postedDate });
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return jobs;
|
|
1130
|
+
})()
|
|
1131
|
+
`.trim();
|
|
1132
|
+
export const browserExtractLiJobsSchema = {
|
|
1133
|
+
sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
|
|
1134
|
+
};
|
|
1135
|
+
export const browserExtractLiJobsDescription = 'Extract LinkedIn job listings as structured JSON ' +
|
|
1136
|
+
'(jobId, title, company, companyUrl, location, salary, jobUrl, logo, promoted, easyApply, postedDate). ' +
|
|
1137
|
+
'Works on both linkedin.com/jobs/ (homepage recommendations) and linkedin.com/jobs/search/?keywords=QUERY (search results). ' +
|
|
1138
|
+
'Navigate to the jobs page first, then scroll down to load more results before extracting. ' +
|
|
1139
|
+
'For search: use linkedin.com/jobs/search/?keywords=QUERY&location=LOCATION. ' +
|
|
1140
|
+
'Returns an array of job objects.';
|
|
1141
|
+
export async function handleBrowserExtractLiJobs(args) {
|
|
1142
|
+
const resolved = await ni.resolveSession(args.sessionId);
|
|
1143
|
+
if ('error' in resolved)
|
|
1144
|
+
return text(resolved.error);
|
|
1145
|
+
const result = await ni.evaluate(resolved.sessionId, LI_JOBS_EXTRACTOR_SCRIPT);
|
|
1146
|
+
if (result.error)
|
|
1147
|
+
return text(`Extract failed: ${result.error}`);
|
|
1148
|
+
const jobs = result.result;
|
|
1149
|
+
if (!Array.isArray(jobs) || jobs.length === 0) {
|
|
1150
|
+
return text('No job listings found. Navigate to linkedin.com/jobs/ or linkedin.com/jobs/search/?keywords=QUERY first, scroll down to load results, then retry.');
|
|
1151
|
+
}
|
|
1152
|
+
return text(`${jobs.length} job(s):\\n\\n${JSON.stringify(jobs, null, 2)}`);
|
|
1153
|
+
}
|
|
1154
|
+
//# sourceMappingURL=browser-observe.js.map
|