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 = `
`
+ 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)}"