neuron-inspector 0.3.1 → 0.4.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.
@@ -162,6 +162,106 @@ export const COMPOUND_TOOLS = [
162
162
  required: ["tabId", "action"],
163
163
  },
164
164
  },
165
+ {
166
+ name: "neuron_vision_act",
167
+ description: "Take a screenshot of the page and describe what's visible, then perform an action based on " +
168
+ "visual understanding — no CSS selectors needed. The extension screenshots the viewport, the " +
169
+ "agent analyzes the image description, and issues click/type commands using element coordinates " +
170
+ "or best-match selectors. Use when you don't know the page structure or selectors keep breaking. " +
171
+ "Describe what you want to interact with in natural language.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ tabId: { type: "number", description: "Chrome tab ID" },
176
+ instruction: {
177
+ type: "string",
178
+ description: "What to do, described visually (e.g. 'click the blue Send button', 'type in the search box at the top', 'scroll to the comments section')",
179
+ },
180
+ screenshot: { type: "boolean", description: "Return the screenshot for the AI to analyze (default: true)" },
181
+ },
182
+ required: ["tabId", "instruction"],
183
+ },
184
+ },
185
+ {
186
+ name: "neuron_approve_via_whatsapp",
187
+ description: "Send an approval request to WhatsApp and wait for the response. Takes a screenshot of the " +
188
+ "current state, sends it with a prompt to a WhatsApp number, waits for approve/reject reply. " +
189
+ "Use instead of pausing for keyboard approval — the user approves from their phone. Requires " +
190
+ "a Neuron bot API key and the recipient's phone number.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ tabId: { type: "number", description: "Chrome tab ID to screenshot (optional)" },
195
+ prompt: { type: "string", description: "The approval question (e.g. 'Send this message to John?')" },
196
+ phone: { type: "string", description: "WhatsApp phone number to send the approval request to (E.164 format)" },
197
+ api_key: { type: "string", description: "Neuron bot API key with 'nrn_' prefix" },
198
+ context: { type: "string", description: "Additional context (e.g. the full message text, the form data)" },
199
+ timeout_seconds: { type: "number", description: "How long to wait for a response (default: 120)" },
200
+ },
201
+ required: ["prompt", "phone", "api_key"],
202
+ },
203
+ },
204
+ {
205
+ name: "neuron_extract_to_json",
206
+ description: "Extract structured data from a page and return it as clean JSON ready for piping to an API, " +
207
+ "spreadsheet, or file. Navigates to the URL, extracts data matching a schema you define, " +
208
+ "and returns normalized rows. Use for building data pipelines — scrape a page and push the " +
209
+ "data to a webhook, save as CSV, or append to a collection.",
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ url: { type: "string", description: "Page URL to extract from" },
214
+ tabId: { type: "number", description: "Use existing tab (optional)" },
215
+ schema: {
216
+ type: "object",
217
+ description: "Expected output schema — keys are field names, values describe what to extract (e.g. {title: 'the post title', price: 'the price as a number', url: 'link to the item'})",
218
+ },
219
+ selector: { type: "string", description: "CSS selector for repeating items (optional — auto-detects)" },
220
+ pages: { type: "number", description: "Number of pages to paginate through (default: 1)" },
221
+ webhook_url: { type: "string", description: "POST extracted data to this URL as JSON (optional)" },
222
+ format: { type: "string", description: "Output format: 'json' (default), 'csv', 'yaml'" },
223
+ },
224
+ required: ["url"],
225
+ },
226
+ },
227
+ {
228
+ name: "neuron_grab_media",
229
+ description: "Extract and download video/audio from any page. Navigates to the URL, plays the media to " +
230
+ "trigger network requests, searches captured traffic for video/audio streams (mp4, m3u8, " +
231
+ "webm, mp3, blob), extracts the CDN URLs, and initiates a browser download. Works on " +
232
+ "Instagram reels, TikTok videos, X/Twitter videos, Facebook videos, LinkedIn videos, YouTube, " +
233
+ "and most sites with embedded video. Returns the download URLs found and download status.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: {
237
+ url: { type: "string", description: "Page URL containing the video (e.g. an Instagram reel URL, TikTok video URL)" },
238
+ filename: { type: "string", description: "Filename to save as (optional — auto-generates from URL)" },
239
+ tabId: { type: "number", description: "Use an existing tab (optional)" },
240
+ waitSeconds: { type: "number", description: "How long to wait for video to start loading (default: 5)" },
241
+ preferQuality: { type: "string", description: "Preferred quality: 'highest', 'lowest', 'auto' (default: highest)" },
242
+ },
243
+ required: ["url"],
244
+ },
245
+ },
246
+ {
247
+ name: "neuron_grab_media_batch",
248
+ description: "Download videos from multiple URLs. Opens each in a tab, extracts video streams, " +
249
+ "downloads all. Up to 5 URLs per call. Returns results for each URL with download status " +
250
+ "and any failures. Use for batch downloading from feeds, trending pages, or saved lists.",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ urls: {
255
+ type: "array",
256
+ items: { type: "string" },
257
+ description: "Video page URLs (max 5)",
258
+ },
259
+ outputDir: { type: "string", description: "Subdirectory name for downloads (optional)" },
260
+ delayMs: { type: "number", description: "Delay between processing each URL in ms (default: 3000)" },
261
+ },
262
+ required: ["urls"],
263
+ },
264
+ },
165
265
  ];
166
266
  // ── Handlers ────────────────────────────────────────────────
167
267
  export async function handleCompoundTool(name, args, ctx) {
@@ -178,6 +278,16 @@ export async function handleCompoundTool(name, args, ctx) {
178
278
  return auditPage(args, ctx);
179
279
  case "neuron_monitor_action":
180
280
  return monitorAction(args, ctx);
281
+ case "neuron_vision_act":
282
+ return visionAct(args, ctx);
283
+ case "neuron_approve_via_whatsapp":
284
+ return approveViaWhatsapp(args, ctx);
285
+ case "neuron_extract_to_json":
286
+ return extractToJson(args, ctx);
287
+ case "neuron_grab_media":
288
+ return grabMedia(args, ctx);
289
+ case "neuron_grab_media_batch":
290
+ return grabMediaBatch(args, ctx);
181
291
  default:
182
292
  throw new Error(`Unknown compound tool: ${name}`);
183
293
  }
@@ -520,4 +630,553 @@ async function monitorAction(args, ctx) {
520
630
  errors,
521
631
  };
522
632
  }
633
+ // ── Vision-based action ─────────────────────────────────────
634
+ async function visionAct(args, ctx) {
635
+ const tabId = args.tabId;
636
+ const instruction = args.instruction;
637
+ const wantScreenshot = args.screenshot ?? true;
638
+ // Screenshot the page
639
+ let screenshot = null;
640
+ if (wantScreenshot) {
641
+ try {
642
+ screenshot = await call(ctx, "captureScreenshot", { tabId });
643
+ }
644
+ catch { /* non-fatal */ }
645
+ }
646
+ // Get page structure for element mapping
647
+ const elements = await call(ctx, "findElements", {
648
+ tabId,
649
+ selectors: [
650
+ "button", "a", "input", "textarea", "select",
651
+ "[role='button']", "[role='link']", "[role='textbox']",
652
+ "[contenteditable='true']", "[onclick]",
653
+ ],
654
+ limit: 50,
655
+ });
656
+ // Get visible text blocks for context
657
+ const pageData = await call(ctx, "extractData", { tabId });
658
+ return {
659
+ tabId,
660
+ instruction,
661
+ screenshot,
662
+ interactive_elements: elements,
663
+ page_content: pageData,
664
+ hint: "Analyze the screenshot and interactive_elements to find what matches the instruction. " +
665
+ "Use the element selectors or text from interactive_elements to call neuron_click or neuron_type. " +
666
+ "If no exact match, try neuron_find_elements with descriptive text from the instruction.",
667
+ };
668
+ }
669
+ // ── WhatsApp approval ───────────────────────────────────────
670
+ async function approveViaWhatsapp(args, ctx) {
671
+ const tabId = args.tabId;
672
+ const prompt = args.prompt;
673
+ const phone = args.phone;
674
+ const apiKey = args.api_key;
675
+ const context = args.context;
676
+ const timeoutSec = args.timeout_seconds ?? 120;
677
+ // Screenshot if tabId provided
678
+ let screenshotData = null;
679
+ if (tabId) {
680
+ try {
681
+ const ss = (await call(ctx, "captureScreenshot", { tabId }));
682
+ screenshotData = ss?.dataUrl ?? null;
683
+ }
684
+ catch { /* non-fatal */ }
685
+ }
686
+ // Build the approval message
687
+ const fullPrompt = context
688
+ ? `${prompt}\n\n---\nContext:\n${context}`
689
+ : prompt;
690
+ // Send approval request via Neuron's approval API (HTTP from the bridge process)
691
+ const approvalPayload = {
692
+ apiKey,
693
+ to: phone,
694
+ prompt: fullPrompt,
695
+ context: context || undefined,
696
+ expiresInSeconds: timeoutSec,
697
+ callback: {
698
+ response: { schemes: ["keyword"] },
699
+ },
700
+ };
701
+ // Use the extension to make the HTTP call (it has the network context)
702
+ // Alternatively, make a direct fetch from the bridge process
703
+ try {
704
+ const response = await fetch("https://api.neuron.ng/api/v1/approvals", {
705
+ method: "POST",
706
+ headers: {
707
+ "Content-Type": "application/json",
708
+ "Authorization": `Bearer ${apiKey}`,
709
+ },
710
+ body: JSON.stringify(approvalPayload),
711
+ });
712
+ if (!response.ok) {
713
+ const errBody = await response.text();
714
+ return {
715
+ status: "approval_send_failed",
716
+ error: `HTTP ${response.status}: ${errBody}`,
717
+ };
718
+ }
719
+ const result = await response.json();
720
+ const approvalId = result?.data?.id;
721
+ if (!approvalId) {
722
+ return { status: "approval_send_failed", error: "No approval ID returned" };
723
+ }
724
+ // Poll for decision
725
+ const startTime = Date.now();
726
+ const pollInterval = 3000;
727
+ while (Date.now() - startTime < timeoutSec * 1000) {
728
+ await sleep(pollInterval);
729
+ try {
730
+ const pollResponse = await fetch(`https://api.neuron.ng/api/v1/approvals/${approvalId}`, {
731
+ headers: { "Authorization": `Bearer ${apiKey}` },
732
+ });
733
+ if (pollResponse.ok) {
734
+ const pollResult = await pollResponse.json();
735
+ const status = pollResult?.data?.status;
736
+ if (status === "approved" || status === "rejected") {
737
+ return {
738
+ status: status,
739
+ approval_id: approvalId,
740
+ decision: pollResult?.data?.decision,
741
+ reason: pollResult?.data?.reason,
742
+ };
743
+ }
744
+ if (status === "expired" || status === "cancelled") {
745
+ return { status: status, approval_id: approvalId };
746
+ }
747
+ }
748
+ }
749
+ catch {
750
+ // Poll failure — retry
751
+ }
752
+ }
753
+ return {
754
+ status: "timeout",
755
+ approval_id: approvalId,
756
+ message: `No response within ${timeoutSec} seconds`,
757
+ };
758
+ }
759
+ catch (err) {
760
+ return {
761
+ status: "error",
762
+ error: err.message,
763
+ };
764
+ }
765
+ }
766
+ // ── Data pipeline extraction ────────────────────────────────
767
+ async function extractToJson(args, ctx) {
768
+ const url = args.url;
769
+ const schema = args.schema;
770
+ const extractSel = args.selector;
771
+ const maxPages = args.pages ?? 1;
772
+ const webhookUrl = args.webhook_url;
773
+ const format = args.format ?? "json";
774
+ // Navigate and extract using search_and_collect for multi-page
775
+ let tabId = args.tabId;
776
+ if (!tabId) {
777
+ const tab = (await call(ctx, "openTab", { url }));
778
+ tabId = tab?.tabId;
779
+ await sleep(2500);
780
+ }
781
+ else {
782
+ await call(ctx, "navigateTo", { tabId, url });
783
+ await sleep(2500);
784
+ }
785
+ if (!tabId)
786
+ throw new Error("Failed to open tab");
787
+ // Collect data across pages
788
+ const allItems = [];
789
+ for (let page = 0; page < maxPages; page++) {
790
+ // Scroll to load lazy content
791
+ for (let i = 0; i < 3; i++) {
792
+ await call(ctx, "scrollPage", { tabId, deltaY: 600, smooth: true });
793
+ await sleep(400);
794
+ }
795
+ const extractArgs = { tabId };
796
+ if (extractSel)
797
+ extractArgs.selector = extractSel;
798
+ const pageData = await call(ctx, "extractData", extractArgs);
799
+ const items = Array.isArray(pageData)
800
+ ? pageData
801
+ : pageData?.items ?? [pageData];
802
+ allItems.push(...items);
803
+ // Paginate if needed
804
+ if (page < maxPages - 1) {
805
+ try {
806
+ await call(ctx, "clickElement", {
807
+ tabId,
808
+ selectors: [
809
+ "button[aria-label='Next']", "a[aria-label='Next']",
810
+ ".pagination-next", "[data-test='pagination-next']",
811
+ "a:has-text('Next')", "button:has-text('Next')",
812
+ ],
813
+ });
814
+ await sleep(2000);
815
+ }
816
+ catch {
817
+ break;
818
+ }
819
+ }
820
+ }
821
+ // Format output
822
+ let output;
823
+ if (format === "csv" && allItems.length > 0) {
824
+ const firstItem = allItems[0];
825
+ const headers = Object.keys(firstItem);
826
+ const csvRows = [headers.join(",")];
827
+ for (const item of allItems) {
828
+ const row = headers.map((h) => {
829
+ const val = String(item[h] ?? "");
830
+ return val.includes(",") || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val;
831
+ });
832
+ csvRows.push(row.join(","));
833
+ }
834
+ output = csvRows.join("\n");
835
+ }
836
+ else if (format === "yaml") {
837
+ // Simple YAML-ish output
838
+ output = allItems.map((item, i) => {
839
+ const entries = Object.entries(item);
840
+ return `- # item ${i + 1}\n` + entries.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n");
841
+ }).join("\n");
842
+ }
843
+ else {
844
+ output = allItems;
845
+ }
846
+ // Send to webhook if specified
847
+ let webhookResult = null;
848
+ if (webhookUrl) {
849
+ try {
850
+ const res = await fetch(webhookUrl, {
851
+ method: "POST",
852
+ headers: { "Content-Type": "application/json" },
853
+ body: JSON.stringify({ source: url, extracted_at: new Date().toISOString(), items: allItems }),
854
+ });
855
+ webhookResult = { status: res.status, ok: res.ok };
856
+ }
857
+ catch (err) {
858
+ webhookResult = { error: err.message };
859
+ }
860
+ }
861
+ return {
862
+ url,
863
+ tabId,
864
+ format,
865
+ total_items: allItems.length,
866
+ pages_collected: Math.min(maxPages, 1),
867
+ data: output,
868
+ schema_hint: schema ?? null,
869
+ webhook: webhookResult,
870
+ };
871
+ }
872
+ // ── Media patterns ──────────────────────────────────────────
873
+ const VIDEO_PATTERNS = [
874
+ ".mp4", ".m3u8", ".webm", ".m4v", ".mov",
875
+ "video/mp4", "video/webm", "video/quicktime",
876
+ "application/x-mpegURL", "application/vnd.apple.mpegurl",
877
+ ];
878
+ const VIDEO_CDN_PATTERNS = [
879
+ /cdninstagram\.com.*\.mp4/,
880
+ /scontent.*\.cdninstagram\.com/,
881
+ /video\.twimg\.com/,
882
+ /pbs\.twimg\.com.*\/vid\//,
883
+ /video\.xx\.fbcdn\.net/,
884
+ /scontent.*\.xx\.fbcdn\.net.*video/,
885
+ /v\d+-webapp.*\.tiktok.*\.com/,
886
+ /pull-.*\.tiktokcdn.*\.com/,
887
+ /googlevideo\.com\/videoplayback/,
888
+ /\.googlevideo\.com/,
889
+ ];
890
+ function scoreMediaUrl(url) {
891
+ // Higher = better quality
892
+ let score = 0;
893
+ if (/1080|hd|high/i.test(url))
894
+ score += 10;
895
+ if (/720/i.test(url))
896
+ score += 7;
897
+ if (/480/i.test(url))
898
+ score += 4;
899
+ if (/360|240|low/i.test(url))
900
+ score += 1;
901
+ if (/\.mp4/i.test(url))
902
+ score += 3;
903
+ if (/\.m3u8/i.test(url))
904
+ score += 1; // HLS needs extra handling
905
+ // Prefer longer URLs (usually contain more path segments = direct CDN)
906
+ if (url.length > 200)
907
+ score += 2;
908
+ return score;
909
+ }
910
+ // ── Media grab ──────────────────────────────────────────────
911
+ async function grabMedia(args, ctx) {
912
+ const url = args.url;
913
+ const filename = args.filename;
914
+ const waitSec = args.waitSeconds ?? 5;
915
+ const preferQuality = args.preferQuality ?? "highest";
916
+ // Open the page
917
+ let tabId = args.tabId;
918
+ if (!tabId) {
919
+ const tab = (await call(ctx, "openTab", { url }));
920
+ tabId = tab?.tabId;
921
+ await sleep(3000);
922
+ }
923
+ else {
924
+ await call(ctx, "navigateTo", { tabId, url });
925
+ await sleep(3000);
926
+ }
927
+ if (!tabId)
928
+ throw new Error("Failed to open tab");
929
+ // Try to click play / interact to start video loading
930
+ try {
931
+ // Common play button selectors across platforms
932
+ await call(ctx, "evaluateJS", {
933
+ tabId,
934
+ expression: `
935
+ // Try clicking play buttons or video elements to trigger load
936
+ const playBtns = document.querySelectorAll(
937
+ 'video, [aria-label*="Play"], [aria-label*="play"], [data-testid*="play"], ' +
938
+ 'button[class*="play"], div[class*="play"], svg[aria-label*="Play"]'
939
+ );
940
+ for (const el of playBtns) {
941
+ if (el instanceof HTMLElement) { el.click(); break; }
942
+ }
943
+ // Also try to play any video element directly
944
+ const video = document.querySelector('video');
945
+ if (video) { video.play().catch(() => {}); }
946
+ 'triggered'
947
+ `,
948
+ });
949
+ }
950
+ catch {
951
+ // Non-fatal — the page might auto-play
952
+ }
953
+ // Wait for video to load and network requests to be captured
954
+ await sleep(waitSec * 1000);
955
+ // Search captured traffic for video URLs
956
+ const candidates = [];
957
+ // Method 1: Search request log for video content types and patterns
958
+ for (const pattern of [".mp4", ".m3u8", ".webm", "video/", "mpegURL"]) {
959
+ try {
960
+ const results = (await call(ctx, "searchTraffic", {
961
+ query: pattern,
962
+ limit: 20,
963
+ }));
964
+ const matches = results?.matches;
965
+ if (Array.isArray(matches)) {
966
+ for (const m of matches) {
967
+ const match = m;
968
+ const mUrl = String(match.url || "");
969
+ if (mUrl && isVideoUrl(mUrl)) {
970
+ candidates.push({
971
+ url: mUrl,
972
+ type: String(match.contentType || guessType(mUrl)),
973
+ source: "traffic_search",
974
+ size: match.size,
975
+ });
976
+ }
977
+ }
978
+ }
979
+ }
980
+ catch {
981
+ // Continue with other patterns
982
+ }
983
+ }
984
+ // Method 2: Check request log filtered by URL patterns
985
+ try {
986
+ const requests = (await call(ctx, "getRecentRequests", {
987
+ tabId,
988
+ limit: 200,
989
+ }));
990
+ const reqList = requests?.requests;
991
+ if (Array.isArray(reqList)) {
992
+ for (const r of reqList) {
993
+ const req = r;
994
+ const rUrl = String(req.url || "");
995
+ if (isVideoUrl(rUrl)) {
996
+ candidates.push({
997
+ url: rUrl,
998
+ type: guessType(rUrl),
999
+ source: "request_log",
1000
+ });
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+ catch {
1006
+ // Non-fatal
1007
+ }
1008
+ // Method 3: Extract video src from DOM
1009
+ try {
1010
+ const domResult = await call(ctx, "evaluateJS", {
1011
+ tabId,
1012
+ expression: `
1013
+ const sources = [];
1014
+ // Direct video elements
1015
+ document.querySelectorAll('video source, video').forEach(el => {
1016
+ const src = el.src || el.getAttribute('src');
1017
+ if (src && !src.startsWith('blob:')) sources.push({ url: src, source: 'video_element' });
1018
+ // Check source children
1019
+ el.querySelectorAll?.('source')?.forEach(s => {
1020
+ const sSrc = s.src || s.getAttribute('src');
1021
+ if (sSrc && !sSrc.startsWith('blob:')) sources.push({ url: sSrc, source: 'source_element' });
1022
+ });
1023
+ });
1024
+ // OG video meta
1025
+ const ogVideo = document.querySelector('meta[property="og:video"]');
1026
+ if (ogVideo?.content) sources.push({ url: ogVideo.content, source: 'og_meta' });
1027
+ const ogVideoUrl = document.querySelector('meta[property="og:video:url"]');
1028
+ if (ogVideoUrl?.content) sources.push({ url: ogVideoUrl.content, source: 'og_meta' });
1029
+ JSON.stringify(sources);
1030
+ `,
1031
+ });
1032
+ const domSources = JSON.parse(String(domResult ?? "[]"));
1033
+ for (const s of domSources) {
1034
+ if (s.url) {
1035
+ candidates.push({ url: s.url, type: guessType(s.url), source: s.source });
1036
+ }
1037
+ }
1038
+ }
1039
+ catch {
1040
+ // Non-fatal
1041
+ }
1042
+ // Deduplicate by URL
1043
+ const seen = new Set();
1044
+ const unique = candidates.filter((c) => {
1045
+ if (seen.has(c.url))
1046
+ return false;
1047
+ seen.add(c.url);
1048
+ return true;
1049
+ });
1050
+ if (unique.length === 0) {
1051
+ return {
1052
+ url,
1053
+ tabId,
1054
+ status: "no_video_found",
1055
+ message: "No downloadable video URL found in network traffic or DOM. The video may use DRM, blob URLs, or require authentication.",
1056
+ candidates_checked: candidates.length,
1057
+ };
1058
+ }
1059
+ // Sort by quality preference
1060
+ unique.sort((a, b) => {
1061
+ const scoreA = scoreMediaUrl(a.url);
1062
+ const scoreB = scoreMediaUrl(b.url);
1063
+ return preferQuality === "lowest" ? scoreA - scoreB : scoreB - scoreA;
1064
+ });
1065
+ const best = unique[0];
1066
+ // Initiate download via the extension
1067
+ let downloadStatus = "url_found";
1068
+ try {
1069
+ const dlFilename = filename || generateFilename(url, best.type);
1070
+ await call(ctx, "evaluateJS", {
1071
+ tabId,
1072
+ expression: `
1073
+ // Use fetch to download, then create a download link
1074
+ fetch("${best.url.replace(/"/g, '\\"')}", { credentials: 'include' })
1075
+ .then(r => r.blob())
1076
+ .then(blob => {
1077
+ const a = document.createElement('a');
1078
+ a.href = URL.createObjectURL(blob);
1079
+ a.download = "${dlFilename.replace(/"/g, '\\"')}";
1080
+ document.body.appendChild(a);
1081
+ a.click();
1082
+ document.body.removeChild(a);
1083
+ URL.revokeObjectURL(a.href);
1084
+ })
1085
+ .catch(() => {
1086
+ // Fallback: open the URL directly
1087
+ window.open("${best.url.replace(/"/g, '\\"')}", '_blank');
1088
+ });
1089
+ 'download_initiated'
1090
+ `,
1091
+ }, 30000);
1092
+ downloadStatus = "download_initiated";
1093
+ }
1094
+ catch {
1095
+ downloadStatus = "url_found_download_failed";
1096
+ }
1097
+ return {
1098
+ url,
1099
+ tabId,
1100
+ status: downloadStatus,
1101
+ best_url: best.url,
1102
+ type: best.type,
1103
+ source: best.source,
1104
+ all_candidates: unique.length,
1105
+ candidates: unique.slice(0, 5).map((c) => ({
1106
+ url: c.url,
1107
+ type: c.type,
1108
+ source: c.source,
1109
+ quality_score: scoreMediaUrl(c.url),
1110
+ })),
1111
+ };
1112
+ }
1113
+ function isVideoUrl(url) {
1114
+ const lower = url.toLowerCase();
1115
+ if (VIDEO_PATTERNS.some((p) => lower.includes(p)))
1116
+ return true;
1117
+ if (VIDEO_CDN_PATTERNS.some((p) => p.test(url)))
1118
+ return true;
1119
+ return false;
1120
+ }
1121
+ function guessType(url) {
1122
+ const lower = url.toLowerCase();
1123
+ if (lower.includes(".mp4") || lower.includes("video/mp4"))
1124
+ return "video/mp4";
1125
+ if (lower.includes(".m3u8") || lower.includes("mpegurl"))
1126
+ return "application/x-mpegURL";
1127
+ if (lower.includes(".webm"))
1128
+ return "video/webm";
1129
+ if (lower.includes(".mp3") || lower.includes("audio/mp"))
1130
+ return "audio/mpeg";
1131
+ return "video/unknown";
1132
+ }
1133
+ function generateFilename(pageUrl, type) {
1134
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1135
+ let platform = "video";
1136
+ if (pageUrl.includes("instagram"))
1137
+ platform = "ig";
1138
+ else if (pageUrl.includes("tiktok"))
1139
+ platform = "tk";
1140
+ else if (pageUrl.includes("x.com") || pageUrl.includes("twitter"))
1141
+ platform = "x";
1142
+ else if (pageUrl.includes("facebook"))
1143
+ platform = "fb";
1144
+ else if (pageUrl.includes("linkedin"))
1145
+ platform = "li";
1146
+ else if (pageUrl.includes("youtube") || pageUrl.includes("youtu.be"))
1147
+ platform = "yt";
1148
+ const ext = type.includes("mp4") ? ".mp4" : type.includes("webm") ? ".webm" : ".mp4";
1149
+ return `${platform}-${ts}${ext}`;
1150
+ }
1151
+ async function grabMediaBatch(args, ctx) {
1152
+ const urls = args.urls.slice(0, 5);
1153
+ const delayMs = args.delayMs ?? 3000;
1154
+ const results = [];
1155
+ for (const url of urls) {
1156
+ try {
1157
+ const result = (await grabMedia({ url, preferQuality: "highest" }, ctx));
1158
+ results.push({
1159
+ url,
1160
+ status: String(result.status || "unknown"),
1161
+ best_url: result.best_url,
1162
+ type: result.type,
1163
+ });
1164
+ }
1165
+ catch (err) {
1166
+ results.push({
1167
+ url,
1168
+ status: "error",
1169
+ error: err.message,
1170
+ });
1171
+ }
1172
+ if (delayMs > 0)
1173
+ await sleep(delayMs);
1174
+ }
1175
+ return {
1176
+ total: results.length,
1177
+ downloaded: results.filter((r) => r.status === "download_initiated").length,
1178
+ failed: results.filter((r) => r.status === "error" || r.status === "no_video_found").length,
1179
+ results,
1180
+ };
1181
+ }
523
1182
  //# sourceMappingURL=compound-tools.js.map