diff --git a/debug/index.html b/debug/index.html index 3939981..c56543b 100644 --- a/debug/index.html +++ b/debug/index.html @@ -388,6 +388,15 @@ white-space: pre-wrap; word-break: break-word; line-height: 1.55; color: var(--text); font-family: inherit; font-size: 12px; } + .advisor-block { + border-left: 2px solid var(--accent2); + margin-bottom: 10px; + padding-left: 10px; + } + .advisor-header { + display: flex; align-items: center; gap: 8px; + margin-bottom: 4px; font-size: 12px; color: var(--text-dim); + } /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 6px; } @@ -812,7 +821,7 @@ return } - const phaseLabels = { '1': 'Phase 1 — Analysis', '2': 'Phase 2 — Execution plan', '3': 'Phase 3 — Critic' } + const phaseLabels = { '1': 'Phase 1 — Analysis', '2': 'Phase 2 — Advisor review', '3': 'Phase 3 — Execution plan' } const phaseClass = { '1': 'phase-1', '2': 'phase-2', '3': 'phase-3' } // Show newest first @@ -838,22 +847,40 @@ const block = document.createElement('div') block.className = 'phase-block' - const tok = (phase.prompt_tokens || 0) + (phase.completion_tokens || 0) - const changed = phase.changed ? 'changed' : '' + // Phase 2 stores advisor results as {Critic: {...}, Pragmatist: {...}, Detailer: {...}} + const isAdvisorPhase = num === '2' && phase && typeof phase === 'object' && !('output' in phase) + + const totalTok = isAdvisorPhase + ? Object.values(phase).reduce((s, a) => s + (a.prompt_tokens||0) + (a.completion_tokens||0), 0) + : (phase.prompt_tokens || 0) + (phase.completion_tokens || 0) const phHdr = document.createElement('div') phHdr.className = 'phase-header' phHdr.innerHTML = ` ${esc(phaseLabels[num] || 'Phase ' + num)} - ${changed} - ${tok.toLocaleString()} tok (↑${phase.prompt_tokens||0} ↓${phase.completion_tokens||0})` + ${totalTok.toLocaleString()} tok` const phBody = document.createElement('div') phBody.className = 'phase-body' - const pre = document.createElement('pre') - pre.textContent = phase.output || '(empty)' - phBody.appendChild(pre) + + if (isAdvisorPhase) { + // Render each advisor as a sub-block + for (const [advisor, data] of Object.entries(phase)) { + const advBlock = document.createElement('div') + advBlock.className = 'advisor-block' + const tok = (data.prompt_tokens||0) + (data.completion_tokens||0) + advBlock.innerHTML = `
${esc(advisor)} ${tok.toLocaleString()} tok
` + const pre = document.createElement('pre') + pre.textContent = data.output || '(empty)' + advBlock.appendChild(pre) + phBody.appendChild(advBlock) + } + } else { + const pre = document.createElement('pre') + pre.textContent = phase.output || '(empty)' + phBody.appendChild(pre) + } const phChevron = phHdr.querySelector('.phase-chevron') phHdr.addEventListener('click', () => { diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index 3aaefa1..6b361d5 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -31,6 +31,7 @@ "share_file" ], "enabled_tools": [ + "instagram_viewer", "todo", "scratchpad", "reflect", "switch_profile", "list_profiles", "web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", diff --git a/test_instagram_api.py b/test_instagram_api.py new file mode 100644 index 0000000..3ac637a --- /dev/null +++ b/test_instagram_api.py @@ -0,0 +1,71 @@ + +import asyncio +import json +import sys +import os + +# Add current directory to sys.path to ensure tools can be imported +sys.path.append(os.getcwd()) + +from tools.instagram_engine import execute + +async def test_instagram_engine(): + print("Starting test for instagram_engine...") + + # Test case 1: Scrape a known public profile (e.g., 'natgeo') + params_success = { + "action": "scrape", + "username": "natgeo", + "limit": 1 + } + + print(f"Testing with params: {params_success}") + try: + result_str = await execute(params_success) + result = json.loads(result_str) + print("Result received successfully.") + print(json.dumps(result, indent=2)) + + if result.get("status") == "success": + print("Test Case 1 (Success Path): PASSED") + elif result.get("status") == "error": + print(f"Test Case 1 (Success Path): FAILED with error: {result.get('message')}") + else: + print(f"Test Case 1 (Success Path): FAILED (Unknown status)") + + except Exception as e: + print(f"Test Case 1 (Success Path): FAILED with exception: {e}") + + # Test case 2: Invalid action + params_invalid_action = { + "action": "delete", + "username": "natgeo" + } + print(f"\nTesting with invalid action: {params_invalid_action}") + try: + result_str = await execute(params_invalid_action) + result = json.loads(result_str) + if "error" in result: + print("Test Case 2 (Invalid Action): PASSED") + else: + print(f"Test Case 2 (Invalid Action): FAILED (Expected error, got: {result})") + except Exception as e: + print(f"Test Case 2 (Invalid/Invalid Action): FAILED with exception: {e}") + + # Test case 3: Missing username + params_missing_username = { + "action": "scrape" + } + print(f"\nTesting with missing username: {params_missing_username}") + try: + result_str = await execute(params_missing_username) + result = json.loads(result_str) + if "error" in result: + print("Test Case 3 (Missing Username): PASSED") + else: + print(f"Test Case 3 (Missing Username): FAILED (Expected error, got: {result})") + except Exception as e: + print(f"Test Case 3 (Missing Username): FAILED with exception: {e}") + +if __name__ == "__main__": + asyncio.run(test_instagram_engine()) diff --git a/tools/enabled.json b/tools/enabled.json index 32cbe36..2f74ce6 100644 --- a/tools/enabled.json +++ b/tools/enabled.json @@ -2,5 +2,6 @@ "get_current_datetime", "text_formatter", "internal_monitor", - "gmail" -] + "gmail", + "instagram_engine" +] \ No newline at end of file diff --git a/tools/instagram_engine.py b/tools/instagram_engine.py new file mode 100644 index 0000000..4b9a799 --- /dev/null +++ b/tools/instagram_engine.py @@ -0,0 +1,225 @@ +import asyncio +import json +import random +import logging +from typing import Optional, Dict, Any + +from playwright.async_api import async_playwright, Page +import playwright_stealth + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("InstagramBrowser") + +class InstagramBrowser: + def __init__(self, proxy: Optional[Dict[str, str]] = None): + """ + Initialize the InstagramBrowser with optional proxy configuration. + :param proxy: Dict with 'server', 'username', 'password' + """ + self.proxy = proxy + self.browser_context_params = {} + if proxy and "server" in proxy: + self.browser_context_params["proxy"] = { + "server": proxy["server"], + "username": proxy.get("username"), + "password": proxy.get("password"), + } + + async def _human_delay(self, min_sec: float = 1.0, max_sec: float = 3.0): + """Implements a random delay to mimic human behavior.""" + delay = random.uniform(min_sec, max_sec) + await asyncio.sleep(delay) + + async def _is_login_wall_present(self, page: Page) -> bool: + """Checks if a login wall or popup is blocking the view.""" + # Common Instagram login selectors + login_selectors = [ + "text='Log in'", + "div[role='dialog']", + "form[action='/accounts/login/']" + ] + for selector in login_selectors: + try: + if await page.locator(selector).is_visible(): + return True + except: + continue + return False + + async def navigate_to_profile(self, page: Page, username: str): + """Navigates to the specified Instagram profile.""" + url = f"https://www.instagram.com/{username}/" + logger.info(f"Navigating to {url}") + try: + await page.goto(url, wait_until="domcontentloaded", timeout=60000) + await self._human_delay() + except Exception as e: + logger.error(f"Navigation failed: {e}") + raise e + + if await self._is_login_wall_present(page): + logger.warning("Login wall detected.") + + async def get_profile_data(self, page: Page) -> Dict[str, Any]: + """Extracts profile information.""" + data = { + "full_name": None, + "biography": None, + "follower_count": None, + "following_count": None, + "post_count": None, + } + + # Full Name + try: + name_locator = page.locator("xpath=//header//h2") + if await name_locator.count() > 0: + data["full_name"] = await name_locator.first.inner_text() + except Exception as e: + logger.warning(f"Failed to extract full_name: {e}") + + # Biography + try: + # Look for elements in header that are likely bio (not name, not counts) + # We look for text nodes that aren't part of the name or counts + bio_elements = page.locator("header div, header span") + count = await bio_elements.count() + for i in range(count): + el = bio_elements.nth(i) + text = await el.inner_text() + clean_text = text.strip() + if clean_text and clean_text not in [data["full_name"], "", " +"]: + # Check if it's not one of the counts + if not any(word in clean_text.lower() for word in ["follower", "following", "post"]): + data["biography"] = clean_text + break + except Exception as e: + logger.warning(f"Failed to extract biography: {e}") + + # Counts (Followers, Following, Posts) + try: + count_elements = page.locator("header a, header span") + count_items = await count_elements.count() + for i in range(count_items): + item = count_elements.nth(i) + text = await item.inner_text() + clean_text = " ".join(text.split()) + + # Look for patterns like '100 followers' or '100 Following' + import re + match = re.search(r'([\d,.]+)\s*(followers|following|posts|post)', clean_text, re.IGNORECASE) + if match: + val = match.group(1).replace(",", "") + label = match.group(2).lower() + if "follower" in label: + data["follower_count"] = val + elif "following" in label: + data["following_count"] = val + elif "post" in label: + data["post_count"] = val + except Exception as e: + logger.warning(f"Failed to extract counts: {n}") + + async def get_recent_posts(self, page: Page, limit: int = 5) -> list: + """Scrapes the recent posts.""" + posts = [] + try: + # Scroll to trigger lazy loading + for _ in range(2): + await page.evaluate("window.scrollBy(0, 800)") + await asyncio.sleep(1) + + # Find all post links that contain '/p/' in their href + post_links = page.locator("a[href*='/p/']") + count = await post_links.count() + + for i in range(min(count, limit)): + post_element = post_links.nth(i) + post_url = await post_element.get_attribute("href") + if post_url: + full_url = f"https://www.instagram.com{post_url}" + + post_data = { + "post_url": full_url, + "caption": None, + "like_count": None, + "comment_count": None + } + posts.append(post_data) + + except Exception as e: + logger.error(f"Error scraping posts: {e}") + + return posts + + async def run_scrape(self, username: str, limit: int = 5) -> str: + """Main entry point for the scraping process.""" + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context(**self.browser_context_params) + page = await context.new_page() + + # Use the generic stealth function if available, or try to apply it manually + try: + # playwright_stealth usually provides stealth_async or stealth_sync + # We'll try to find it in the module + if hasattr(playwright_stealth, 'stealth_async'): + await playwright_stealth.stealth_async(page) + elif hasattr(playwright_stealth, 'stealth_sync'): + # stealth_sync works on the page object too in some versions + await playwright_stealth.stealth_sync(page) + else: + # Fallback: if we can't use stealth, we proceed without it + logger.warning("Stealth module failed to provide stealth_async. Proceeding without stealth.") + except Exception as stealth_err: + logger.warning(f"Stealth application failed: {stealth_err}") + + await self.navigate_to_profile(page, username) + + profile_data = await self.get_profile_data(page) + recent_posts = await self.get_recent_posts(page, limit=limit) + + result = { + "username": username, + "profile": profile_data, + "recent_posts": recent_posts, + "status": "success" + } + + await browser.close() + return json.dumps(result, indent=2) + + except Exception as e: + error_msg = {"username": username, "status": "error", "message": str(e)} + return json.dumps(error_msg, indent=2) + +name = "instagram_engine" +description = "Core engine for Instagram browser automation." +parameters = { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["scrape"]}, + "username": {"type": "string"}, + "proxy": {"type": "object", "description": "Proxy config"}, + "limit": {"type": "integer", "description": "Number of posts to scrape"} + }, + "required": ["action", "username"], +} + +async def execute(params: dict) -> str: + action = params.get("action") + username = params.get("username") + proxy = params.get("proxy") + limit = params.get("limit", 5) + + if action != "scrape": + return json.dumps({"error": "Unsupported action"}, indent=2) + + if not username: + return json.dumps({"error": "Username is required"}, indent=2) + + engine = InstagramBrowser(proxy=proxy) + return await engine.run_scrape(username, limit=limit) diff --git a/tools/instagram_viewer.py b/tools/instagram_viewer.py new file mode 100644 index 0000000..b76344e --- /dev/null +++ b/tools/instagram_viewer.py @@ -0,0 +1,73 @@ +import json +from tools.instagram_engine import execute as engine_execute + +name = "instagram_viewer" +description = "View Instagram profiles, posts, and metadata using browser automation." +parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["profile", "posts"], + "description": "Action to perform: 'profile' for metadata, 'post' for recent posts.", + }, + "username": { + "type": "string", + "description": "The Instagram username to view.", + }, + "limit": { + "type": "integer", + "description": "Number of posts to retrieve (only for 'posts' action).", + "default": 5, + }, + "proxy": { + "type": "object", + "description": "Proxy configuration: {'server': '...', 'username': '...', 'password': '...'}", + }, + }, + "required": ["action", "username"], +} + +async def execute(params: dict) -> str: + action = params.get("action") + username = params.get("username") + limit = params.get("limit", 5) + proxy = params.get("proxy") + + # The engine handles the heavy lifting. We just pass the parameters through. + # We map our tool's 'action' to the engine's 'scrape' action. + engine_params = { + "action": "scrape", + "username": username, + "limit": limit, + "proxy": proxy + } + + try: + result_json = await engine_execute(engine_params) + result = json.loads(result_json) + + if result.get("status") == "error": + return f"Error viewing Instagram profile: {result.get('message')}" + + if action == "profile": + # Return only profile metadata + profile_info = { + "username": result.get("username"), + "profile": result.get("profile") + } + return json.dumps(profile_info, indent=2, ensure_ascii=False) + + elif action == "posts": + # Return only posts + posts_info = { + "username": result.get("username"), + "recent_posts": result.get("recent_posts") + } + return json.dumps(posts_info, indent=2, ensure_ascii=False) + + else: + return "Unsupported action. Use 'profile' or 'posts'." + + except Exception as e: + return f"An error occurred while executing the Instagram viewer: {str(e)}"