diff --git a/cli-manifest.json b/cli-manifest.json new file mode 100644 index 000000000..92edddbfa --- /dev/null +++ b/cli-manifest.json @@ -0,0 +1,7473 @@ +[ + { + "site": "36kr", + "name": "article", + "description": "获取36氪文章正文内容", + "domain": "www.36kr.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Article ID or full 36kr article URL" + } + ], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "36kr/article.js", + "sourceFile": "36kr\\article.ts" + }, + { + "site": "36kr", + "name": "hot", + "description": "36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)", + "domain": "www.36kr.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of items (max 50)" + }, + { + "name": "type", + "type": "string", + "default": "catalog", + "required": false, + "help": "List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)" + } + ], + "columns": [ + "rank", + "title", + "url" + ], + "type": "ts", + "modulePath": "36kr/hot.js", + "sourceFile": "36kr\\hot.ts" + }, + { + "site": "36kr", + "name": "news", + "description": "Latest tech/startup news from 36kr (36氪)", + "domain": "www.36kr.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of articles (max 50)" + } + ], + "columns": [ + "rank", + "title", + "summary", + "date", + "url" + ], + "type": "ts", + "modulePath": "36kr/news.js", + "sourceFile": "36kr\\news.ts" + }, + { + "site": "36kr", + "name": "search", + "description": "搜索36氪文章", + "domain": "www.36kr.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword (e.g. \"AI\", \"OpenAI\")" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results (max 50)" + } + ], + "columns": [ + "rank", + "title", + "date", + "url" + ], + "type": "ts", + "modulePath": "36kr/search.js", + "sourceFile": "36kr\\search.ts" + }, + { + "site": "antigravity", + "name": "dump", + "description": "Dump the DOM to help AI understand the UI", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "htmlFile", + "snapFile" + ], + "type": "ts", + "modulePath": "antigravity/dump.js", + "sourceFile": "antigravity\\dump.ts" + }, + { + "site": "antigravity", + "name": "extract-code", + "description": "Extract multi-line code blocks from the current Antigravity conversation", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "code" + ], + "type": "ts", + "modulePath": "antigravity/extract-code.js", + "sourceFile": "antigravity\\extract-code.ts" + }, + { + "site": "antigravity", + "name": "model", + "description": "Switch the active LLM model in Antigravity", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "Target model name (e.g. claude, gemini, o1)" + } + ], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "antigravity/model.js", + "sourceFile": "antigravity\\model.ts" + }, + { + "site": "antigravity", + "name": "new", + "description": "Start a new conversation / clear context in Antigravity", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "status" + ], + "type": "ts", + "modulePath": "antigravity/new.js", + "sourceFile": "antigravity\\new.ts" + }, + { + "site": "antigravity", + "name": "read", + "description": "Read the latest chat messages from Antigravity AI", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "last", + "type": "str", + "required": false, + "help": "Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)" + } + ], + "columns": [ + "role", + "content" + ], + "type": "ts", + "modulePath": "antigravity/read.js", + "sourceFile": "antigravity\\read.ts" + }, + { + "site": "antigravity", + "name": "send", + "description": "Send a message to Antigravity AI via the internal Lexical editor", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "message", + "type": "str", + "required": true, + "positional": true, + "help": "The message text to send" + } + ], + "columns": [ + "Status", + "Message" + ], + "type": "ts", + "modulePath": "antigravity/send.js", + "sourceFile": "antigravity\\send.ts" + }, + { + "site": "antigravity", + "name": "status", + "description": "Check Antigravity CDP connection and get current page state", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "status", + "url", + "title" + ], + "type": "ts", + "modulePath": "antigravity/status.js", + "sourceFile": "antigravity\\status.ts" + }, + { + "site": "antigravity", + "name": "watch", + "description": "Stream new chat messages from Antigravity in real-time", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [], + "timeout": 86400, + "type": "ts", + "modulePath": "antigravity/watch.js", + "sourceFile": "antigravity\\watch.ts" + }, + { + "site": "apple-podcasts", + "name": "top", + "description": "Top podcasts chart on Apple Podcasts", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of podcasts (max 100)" + }, + { + "name": "country", + "type": "str", + "default": "us", + "required": false, + "help": "Country code (e.g. us, cn, gb, jp)" + } + ], + "columns": [ + "rank", + "title", + "author", + "id" + ], + "type": "ts", + "modulePath": "apple-podcasts/top.js", + "sourceFile": "apple-podcasts\\top.ts" + }, + { + "site": "band", + "name": "bands", + "description": "List all Bands you belong to", + "domain": "www.band.us", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "band_no", + "name", + "members" + ], + "type": "ts", + "modulePath": "band/bands.js", + "sourceFile": "band\\bands.ts" + }, + { + "site": "band", + "name": "mentions", + "description": "Show Band notifications where you are @mentioned", + "domain": "www.band.us", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "filter", + "type": "str", + "default": "mentioned", + "required": false, + "help": "Filter: mentioned (default) | all | post | comment", + "choices": [ + "mentioned", + "all", + "post", + "comment" + ] + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results" + }, + { + "name": "unread", + "type": "bool", + "default": false, + "required": false, + "help": "Show only unread notifications" + } + ], + "columns": [ + "time", + "band", + "type", + "from", + "text", + "url" + ], + "type": "ts", + "modulePath": "band/mentions.js", + "sourceFile": "band\\mentions.ts" + }, + { + "site": "band", + "name": "post", + "description": "Export full content of a post including comments", + "domain": "www.band.us", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "band_no", + "type": "int", + "required": true, + "positional": true, + "help": "Band number" + }, + { + "name": "post_no", + "type": "int", + "required": true, + "positional": true, + "help": "Post number" + }, + { + "name": "output", + "type": "str", + "default": "", + "required": false, + "help": "Directory to save attached photos" + }, + { + "name": "comments", + "type": "bool", + "default": true, + "required": false, + "help": "Include comments (default: true)" + } + ], + "columns": [ + "type", + "author", + "date", + "text" + ], + "type": "ts", + "modulePath": "band/post.js", + "sourceFile": "band\\post.ts", + "navigateBefore": false + }, + { + "site": "band", + "name": "posts", + "description": "List posts from a Band", + "domain": "www.band.us", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "band_no", + "type": "int", + "required": true, + "positional": true, + "help": "Band number (get it from: band bands)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results" + } + ], + "columns": [ + "date", + "author", + "content", + "comments", + "url" + ], + "type": "ts", + "modulePath": "band/posts.js", + "sourceFile": "band\\posts.ts", + "navigateBefore": false + }, + { + "site": "barchart", + "name": "flow", + "description": "Barchart unusual options activity / options flow", + "domain": "www.barchart.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "type", + "type": "str", + "default": "all", + "required": false, + "help": "Filter: all, call, or put", + "choices": [ + "all", + "call", + "put" + ] + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "symbol", + "type", + "strike", + "expiration", + "last", + "volume", + "openInterest", + "volOiRatio", + "iv" + ], + "type": "ts", + "modulePath": "barchart/flow.js", + "sourceFile": "barchart\\flow.ts" + }, + { + "site": "barchart", + "name": "greeks", + "description": "Barchart options greeks overview (IV, delta, gamma, theta, vega)", + "domain": "www.barchart.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Stock ticker (e.g. AAPL)" + }, + { + "name": "expiration", + "type": "str", + "required": false, + "help": "Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration." + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of near-the-money strikes per type" + } + ], + "columns": [ + "type", + "strike", + "last", + "iv", + "delta", + "gamma", + "theta", + "vega", + "rho", + "volume", + "openInterest", + "expiration" + ], + "type": "ts", + "modulePath": "barchart/greeks.js", + "sourceFile": "barchart\\greeks.ts" + }, + { + "site": "barchart", + "name": "options", + "description": "Barchart options chain with greeks, IV, volume, and open interest", + "domain": "www.barchart.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Stock ticker (e.g. AAPL)" + }, + { + "name": "type", + "type": "str", + "default": "Call", + "required": false, + "help": "Option type: Call or Put", + "choices": [ + "Call", + "Put" + ] + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max number of strikes to return" + } + ], + "columns": [ + "strike", + "bid", + "ask", + "last", + "change", + "volume", + "openInterest", + "iv", + "delta", + "gamma", + "theta", + "vega", + "expiration" + ], + "type": "ts", + "modulePath": "barchart/options.js", + "sourceFile": "barchart\\options.ts" + }, + { + "site": "barchart", + "name": "quote", + "description": "Barchart stock quote with price, volume, and key metrics", + "domain": "www.barchart.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Stock ticker (e.g. AAPL, MSFT, TSLA)" + } + ], + "columns": [ + "symbol", + "name", + "price", + "change", + "changePct", + "open", + "high", + "low", + "prevClose", + "volume", + "avgVolume", + "marketCap", + "peRatio", + "eps" + ], + "type": "ts", + "modulePath": "barchart/quote.js", + "sourceFile": "barchart\\quote.ts" + }, + { + "site": "bbc", + "name": "news", + "description": "BBC News headlines (RSS)", + "domain": "www.bbc.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of headlines (max 50)" + } + ], + "columns": [ + "rank", + "title", + "description", + "url" + ], + "type": "ts", + "modulePath": "bbc/news.js", + "sourceFile": "bbc\\news.ts" + }, + { + "site": "bilibili", + "name": "hot", + "description": "B站热门视频", + "domain": "www.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of videos" + } + ], + "columns": [ + "rank", + "title", + "author", + "play", + "danmaku" + ], + "type": "ts", + "modulePath": "bilibili/hot.js", + "sourceFile": "bilibili\\hot.ts" + }, + { + "site": "bluesky", + "name": "feeds", + "description": "Popular Bluesky feed generators", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of feeds" + } + ], + "columns": [ + "rank", + "name", + "likes", + "creator", + "description" + ], + "type": "ts", + "modulePath": "bluesky/feeds.js", + "sourceFile": "bluesky\\feeds.ts" + }, + { + "site": "bluesky", + "name": "followers", + "description": "List followers of a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of followers" + } + ], + "columns": [ + "rank", + "handle", + "name", + "description" + ], + "type": "ts", + "modulePath": "bluesky/followers.js", + "sourceFile": "bluesky\\followers.ts" + }, + { + "site": "bluesky", + "name": "following", + "description": "List accounts a Bluesky user is following", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of accounts" + } + ], + "columns": [ + "rank", + "handle", + "name", + "description" + ], + "type": "ts", + "modulePath": "bluesky/following.js", + "sourceFile": "bluesky\\following.ts" + }, + { + "site": "bluesky", + "name": "profile", + "description": "Get Bluesky user profile info", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle (e.g. bsky.app, jay.bsky.team)" + } + ], + "columns": [ + "handle", + "name", + "followers", + "following", + "posts", + "description" + ], + "type": "ts", + "modulePath": "bluesky/profile.js", + "sourceFile": "bluesky\\profile.ts" + }, + { + "site": "bluesky", + "name": "search", + "description": "Search Bluesky users", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "handle", + "name", + "followers", + "description" + ], + "type": "ts", + "modulePath": "bluesky/search.js", + "sourceFile": "bluesky\\search.ts" + }, + { + "site": "bluesky", + "name": "starter-packs", + "description": "Get starter packs created by a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of starter packs" + } + ], + "columns": [ + "rank", + "name", + "description", + "members", + "joins" + ], + "type": "ts", + "modulePath": "bluesky/starter-packs.js", + "sourceFile": "bluesky\\starter-packs.ts" + }, + { + "site": "bluesky", + "name": "thread", + "description": "Get a Bluesky post thread with replies", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "uri", + "type": "str", + "required": true, + "positional": true, + "help": "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of replies" + } + ], + "columns": [ + "author", + "text", + "likes", + "reposts", + "replies_count" + ], + "type": "ts", + "modulePath": "bluesky/thread.js", + "sourceFile": "bluesky\\thread.ts" + }, + { + "site": "bluesky", + "name": "trending", + "description": "Trending topics on Bluesky", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of topics" + } + ], + "columns": [ + "rank", + "topic", + "link" + ], + "type": "ts", + "modulePath": "bluesky/trending.js", + "sourceFile": "bluesky\\trending.ts" + }, + { + "site": "bluesky", + "name": "user", + "description": "Get recent posts from a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle (e.g. bsky.app)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "text", + "likes", + "reposts", + "replies" + ], + "type": "ts", + "modulePath": "bluesky/user.js", + "sourceFile": "bluesky\\user.ts" + }, + { + "site": "chatgpt", + "name": "new", + "description": "Open a new chat in ChatGPT Desktop App", + "domain": "localhost", + "strategy": "public", + "browser": false, + "args": [], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "chatgpt/new.js", + "sourceFile": "chatgpt\\new.ts" + }, + { + "site": "chatgpt", + "name": "status", + "description": "Check if ChatGPT Desktop App is running natively on macOS", + "domain": "localhost", + "strategy": "public", + "browser": false, + "args": [], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "chatgpt/status.js", + "sourceFile": "chatgpt\\status.ts" + }, + { + "site": "chatwise", + "name": "ask", + "description": "Send a prompt and wait for the AI response (send + wait + read)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt to send" + }, + { + "name": "timeout", + "type": "str", + "default": "30", + "required": false, + "help": "Max seconds to wait (default: 30)" + } + ], + "columns": [ + "Role", + "Text" + ], + "type": "ts", + "modulePath": "chatwise/ask.js", + "sourceFile": "chatwise\\ask.ts" + }, + { + "site": "chatwise", + "name": "export", + "description": "Export the current ChatWise conversation to a Markdown file", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file (default: /tmp/chatwise-export.md)" + } + ], + "columns": [ + "Status", + "File", + "Messages" + ], + "type": "ts", + "modulePath": "chatwise/export.js", + "sourceFile": "chatwise\\export.ts" + }, + { + "site": "chatwise", + "name": "history", + "description": "List conversation history in ChatWise sidebar", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Title" + ], + "type": "ts", + "modulePath": "chatwise/history.js", + "sourceFile": "chatwise\\history.ts" + }, + { + "site": "chatwise", + "name": "model", + "description": "Get or switch the active AI model in ChatWise", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "model-name", + "type": "str", + "required": false, + "positional": true, + "help": "Model to switch to (e.g. gpt-4, claude-3)" + } + ], + "columns": [ + "Status", + "Model" + ], + "type": "ts", + "modulePath": "chatwise/model.js", + "sourceFile": "chatwise\\model.ts" + }, + { + "site": "chatwise", + "name": "read", + "description": "Read the current ChatWise conversation history", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Content" + ], + "type": "ts", + "modulePath": "chatwise/read.js", + "sourceFile": "chatwise\\read.ts" + }, + { + "site": "chatwise", + "name": "send", + "description": "Send a message to the active ChatWise conversation", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Message to send" + } + ], + "columns": [ + "Status", + "InjectedText" + ], + "type": "ts", + "modulePath": "chatwise/send.js", + "sourceFile": "chatwise\\send.ts" + }, + { + "site": "codex", + "name": "ask", + "description": "Send a prompt and wait for the AI response (send + wait + read)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt to send" + }, + { + "name": "timeout", + "type": "str", + "default": "60", + "required": false, + "help": "Max seconds to wait for response (default: 60)" + } + ], + "columns": [ + "Role", + "Text" + ], + "type": "ts", + "modulePath": "codex/ask.js", + "sourceFile": "codex\\ask.ts" + }, + { + "site": "codex", + "name": "export", + "description": "Export the current Codex conversation to a Markdown file", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file (default: /tmp/codex-export.md)" + } + ], + "columns": [ + "Status", + "File", + "Messages" + ], + "type": "ts", + "modulePath": "codex/export.js", + "sourceFile": "codex\\export.ts" + }, + { + "site": "codex", + "name": "extract-diff", + "description": "Extract visual code review diff patches from Codex", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "File", + "Diff" + ], + "type": "ts", + "modulePath": "codex/extract-diff.js", + "sourceFile": "codex\\extract-diff.ts" + }, + { + "site": "codex", + "name": "history", + "description": "List recent conversation threads in Codex", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Title" + ], + "type": "ts", + "modulePath": "codex/history.js", + "sourceFile": "codex\\history.ts" + }, + { + "site": "codex", + "name": "model", + "description": "Get or switch the currently active AI model in Codex Desktop", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "model-name", + "type": "str", + "required": false, + "positional": true, + "help": "The ID of the model to switch to (e.g. gpt-4)" + } + ], + "columns": [ + "Status", + "Model" + ], + "type": "ts", + "modulePath": "codex/model.js", + "sourceFile": "codex\\model.ts" + }, + { + "site": "codex", + "name": "read", + "description": "Read the contents of the current Codex conversation thread", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Content" + ], + "type": "ts", + "modulePath": "codex/read.js", + "sourceFile": "codex\\read.ts" + }, + { + "site": "codex", + "name": "send", + "description": "Send text/commands to the Codex AI composer", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text, command (e.g. /review), or skill (e.g. $imagegen)" + } + ], + "columns": [ + "Status", + "InjectedText" + ], + "type": "ts", + "modulePath": "codex/send.js", + "sourceFile": "codex\\send.ts" + }, + { + "site": "ctrip", + "name": "search", + "description": "搜索携程目的地、景区和酒店联想结果", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword (city or attraction)" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "name", + "type", + "score", + "price", + "url" + ], + "type": "ts", + "modulePath": "ctrip/search.js", + "sourceFile": "ctrip\\search.ts" + }, + { + "site": "cursor", + "name": "ask", + "description": "Send a prompt and wait for the AI response (send + wait + read)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt to send" + }, + { + "name": "timeout", + "type": "str", + "default": "30", + "required": false, + "help": "Max seconds to wait for response (default: 30)" + } + ], + "columns": [ + "Role", + "Text" + ], + "type": "ts", + "modulePath": "cursor/ask.js", + "sourceFile": "cursor\\ask.ts" + }, + { + "site": "cursor", + "name": "composer", + "description": "Send a prompt directly into Cursor Composer (Cmd+I shortcut)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text to send into Composer" + } + ], + "columns": [ + "Status", + "InjectedText" + ], + "type": "ts", + "modulePath": "cursor/composer.js", + "sourceFile": "cursor\\composer.ts" + }, + { + "site": "cursor", + "name": "export", + "description": "Export the current cursor conversation to a Markdown file", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file (default: /tmp/cursor-export.md)" + } + ], + "columns": [ + "Status", + "File", + "Messages" + ], + "type": "ts", + "modulePath": "cursor/export.js", + "sourceFile": "cursor\\export.ts" + }, + { + "site": "cursor", + "name": "extract-code", + "description": "Extract multi-line code blocks from the current Cursor conversation", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Code" + ], + "type": "ts", + "modulePath": "cursor/extract-code.js", + "sourceFile": "cursor\\extract-code.ts" + }, + { + "site": "cursor", + "name": "history", + "description": "List recent chat sessions from the Cursor sidebar", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Title" + ], + "type": "ts", + "modulePath": "cursor/history.js", + "sourceFile": "cursor\\history.ts" + }, + { + "site": "cursor", + "name": "model", + "description": "Get or switch the currently active AI model in Cursor", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "model-name", + "type": "str", + "required": false, + "positional": true, + "help": "The ID of the model to switch to (e.g. claude-3.5-sonnet)" + } + ], + "columns": [ + "Status", + "Model" + ], + "type": "ts", + "modulePath": "cursor/model.js", + "sourceFile": "cursor\\model.ts" + }, + { + "site": "cursor", + "name": "read", + "description": "Read the current Cursor chat/composer conversation history", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Role", + "Text" + ], + "type": "ts", + "modulePath": "cursor/read.js", + "sourceFile": "cursor\\read.ts" + }, + { + "site": "cursor", + "name": "send", + "description": "Send a prompt directly into Cursor Composer/Chat", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text to send into Cursor" + } + ], + "columns": [ + "Status", + "InjectedText" + ], + "type": "ts", + "modulePath": "cursor/send.js", + "sourceFile": "cursor\\send.ts" + }, + { + "site": "devto", + "name": "tag", + "description": "Latest DEV.to articles for a specific tag", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "tag", + "type": "str", + "required": true, + "positional": true, + "help": "Tag name (e.g. javascript, python, webdev)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "author", + "reactions", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "devto/tag.js", + "sourceFile": "devto\\tag.ts" + }, + { + "site": "devto", + "name": "top", + "description": "Top DEV.to articles of the day", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "author", + "reactions", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "devto/top.js", + "sourceFile": "devto\\top.ts" + }, + { + "site": "devto", + "name": "user", + "description": "Recent DEV.to articles from a specific user", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "DEV.to username (e.g. ben, thepracticaldev)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "reactions", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "devto/user.js", + "sourceFile": "devto\\user.ts" + }, + { + "site": "dictionary", + "name": "examples", + "description": "Read real-world example sentences utilizing the word", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to get example sentences for" + } + ], + "columns": [ + "word", + "example" + ], + "type": "ts", + "modulePath": "dictionary/examples.js", + "sourceFile": "dictionary\\examples.ts" + }, + { + "site": "dictionary", + "name": "search", + "description": "Search the Free Dictionary API for definitions, parts of speech, and pronunciations.", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to define (e.g., serendipity)" + } + ], + "columns": [ + "word", + "phonetic", + "type", + "definition" + ], + "type": "ts", + "modulePath": "dictionary/search.js", + "sourceFile": "dictionary\\search.ts" + }, + { + "site": "dictionary", + "name": "synonyms", + "description": "Find synonyms for a specific word", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to find synonyms for (e.g., serendipity)" + } + ], + "columns": [ + "word", + "synonyms" + ], + "type": "ts", + "modulePath": "dictionary/synonyms.js", + "sourceFile": "dictionary\\synonyms.ts" + }, + { + "site": "discord-app", + "name": "channels", + "description": "List channels in the current Discord server", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Channel", + "Type" + ], + "type": "ts", + "modulePath": "discord-app/channels.js", + "sourceFile": "discord-app\\channels.ts" + }, + { + "site": "discord-app", + "name": "members", + "description": "List online members in the current Discord channel", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Name", + "Status" + ], + "type": "ts", + "modulePath": "discord-app/members.js", + "sourceFile": "discord-app\\members.ts" + }, + { + "site": "discord-app", + "name": "read", + "description": "Read recent messages from the active Discord channel", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "count", + "type": "str", + "default": "20", + "required": false, + "help": "Number of messages to read (default: 20)" + } + ], + "columns": [ + "Author", + "Time", + "Message" + ], + "type": "ts", + "modulePath": "discord-app/read.js", + "sourceFile": "discord-app\\read.ts" + }, + { + "site": "discord-app", + "name": "search", + "description": "Search messages in the current Discord server/channel (Cmd+F)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + } + ], + "columns": [ + "Index", + "Author", + "Message" + ], + "type": "ts", + "modulePath": "discord-app/search.js", + "sourceFile": "discord-app\\search.ts" + }, + { + "site": "discord-app", + "name": "send", + "description": "Send a message in the active Discord channel", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Message to send" + } + ], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "discord-app/send.js", + "sourceFile": "discord-app\\send.ts" + }, + { + "site": "discord-app", + "name": "servers", + "description": "List all Discord servers (guilds) in the sidebar", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Server" + ], + "type": "ts", + "modulePath": "discord-app/servers.js", + "sourceFile": "discord-app\\servers.ts" + }, + { + "site": "discord-app", + "name": "status", + "description": "Check active CDP connection to Discord Desktop", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Url", + "Title" + ], + "type": "ts", + "modulePath": "discord-app/status.js", + "sourceFile": "discord-app\\status.ts" + }, + { + "site": "douban", + "name": "subject", + "description": "获取电影详情", + "domain": "movie.douban.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "电影 ID" + } + ], + "columns": [ + "id", + "title", + "originalTitle", + "year", + "rating", + "ratingCount", + "genres", + "directors", + "casts", + "country", + "duration", + "summary", + "url" + ], + "type": "ts", + "modulePath": "douban/subject.js", + "sourceFile": "douban\\subject.ts" + }, + { + "site": "douban", + "name": "top250", + "description": "豆瓣电影 Top250", + "domain": "movie.douban.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 250, + "required": false, + "help": "返回结果数量" + } + ], + "columns": [ + "rank", + "id", + "title", + "rating", + "url" + ], + "type": "ts", + "modulePath": "douban/top250.js", + "sourceFile": "douban\\top250.ts" + }, + { + "site": "doubao-app", + "name": "dump", + "description": "Dump Doubao desktop app DOM and snapshot to /tmp for debugging", + "domain": "doubao-app", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "File" + ], + "type": "ts", + "modulePath": "doubao-app/dump.js", + "sourceFile": "doubao-app\\dump.ts" + }, + { + "site": "doubao-app", + "name": "screenshot", + "description": "Capture a screenshot of the Doubao desktop app window", + "domain": "doubao-app", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file path (default: /tmp/doubao-screenshot.png)" + } + ], + "columns": [ + "Status", + "File" + ], + "type": "ts", + "modulePath": "doubao-app/screenshot.js", + "sourceFile": "doubao-app\\screenshot.ts" + }, + { + "site": "doubao-app", + "name": "status", + "description": "Check CDP connection to Doubao desktop app", + "domain": "doubao-app", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Url", + "Title" + ], + "type": "ts", + "modulePath": "doubao-app/status.js", + "sourceFile": "doubao-app\\status.ts" + }, + { + "site": "douyin", + "name": "draft", + "description": "上传视频并保存为草稿", + "domain": "creator.douyin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "video", + "type": "str", + "required": true, + "positional": true, + "help": "视频文件路径" + }, + { + "name": "title", + "type": "str", + "required": true, + "help": "视频标题(≤30字)" + }, + { + "name": "caption", + "type": "str", + "default": "", + "required": false, + "help": "正文内容(≤1000字,支持 #话题)" + }, + { + "name": "cover", + "type": "str", + "default": "", + "required": false, + "help": "封面图片路径" + }, + { + "name": "visibility", + "type": "str", + "default": "public", + "required": false, + "help": "", + "choices": [ + "public", + "friends", + "private" + ] + } + ], + "columns": [ + "status", + "draft_id" + ], + "type": "ts", + "modulePath": "douyin/draft.js", + "sourceFile": "douyin\\draft.ts", + "navigateBefore": false + }, + { + "site": "facebook", + "name": "add-friend", + "description": "Send a friend request on Facebook", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Facebook username or profile URL" + } + ], + "columns": [ + "status", + "username" + ], + "type": "ts", + "modulePath": "facebook/add-friend.js", + "sourceFile": "facebook\\add-friend.ts" + }, + { + "site": "facebook", + "name": "events", + "description": "Browse Facebook event categories", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "Number of categories" + } + ], + "columns": [ + "index", + "name" + ], + "type": "ts", + "modulePath": "facebook/events.js", + "sourceFile": "facebook\\events.ts" + }, + { + "site": "facebook", + "name": "feed", + "description": "Get your Facebook news feed", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "index", + "author", + "content", + "likes", + "comments", + "shares" + ], + "type": "ts", + "modulePath": "facebook/feed.js", + "sourceFile": "facebook\\feed.ts" + }, + { + "site": "facebook", + "name": "friends", + "description": "Get Facebook friend suggestions", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of friend suggestions" + } + ], + "columns": [ + "index", + "name", + "mutual" + ], + "type": "ts", + "modulePath": "facebook/friends.js", + "sourceFile": "facebook\\friends.ts" + }, + { + "site": "facebook", + "name": "groups", + "description": "List your Facebook groups", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of groups" + } + ], + "columns": [ + "index", + "name", + "last_post", + "url" + ], + "type": "ts", + "modulePath": "facebook/groups.js", + "sourceFile": "facebook\\groups.ts" + }, + { + "site": "facebook", + "name": "join-group", + "description": "Join a Facebook group", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "group", + "type": "str", + "required": true, + "positional": true, + "help": "Group ID or URL path (e.g. '1876150192925481' or group name)" + } + ], + "columns": [ + "status", + "group" + ], + "type": "ts", + "modulePath": "facebook/join-group.js", + "sourceFile": "facebook\\join-group.ts" + }, + { + "site": "facebook", + "name": "memories", + "description": "Get your Facebook memories (On This Day)", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of memories" + } + ], + "columns": [ + "index", + "source", + "content", + "time" + ], + "type": "ts", + "modulePath": "facebook/memories.js", + "sourceFile": "facebook\\memories.ts" + }, + { + "site": "facebook", + "name": "notifications", + "description": "Get recent Facebook notifications", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "Number of notifications" + } + ], + "columns": [ + "index", + "text", + "time" + ], + "type": "ts", + "modulePath": "facebook/notifications.js", + "sourceFile": "facebook\\notifications.ts" + }, + { + "site": "facebook", + "name": "profile", + "description": "Get Facebook user/page profile info", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Facebook username or page name" + } + ], + "columns": [ + "name", + "username", + "friends", + "followers", + "url" + ], + "type": "ts", + "modulePath": "facebook/profile.js", + "sourceFile": "facebook\\profile.ts" + }, + { + "site": "facebook", + "name": "search", + "description": "Search Facebook for people, pages, or posts", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "index", + "title", + "text", + "url" + ], + "type": "ts", + "modulePath": "facebook/search.js", + "sourceFile": "facebook\\search.ts" + }, + { + "site": "gitee", + "name": "search", + "description": "Search repositories on Gitee", + "domain": "gitee.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results (max 50)" + } + ], + "columns": [ + "rank", + "name", + "language", + "stars", + "description", + "url" + ], + "type": "ts", + "modulePath": "gitee/search.js", + "sourceFile": "gitee\\search.ts" + }, + { + "site": "gitee", + "name": "trending", + "description": "Recommended open-source projects on Gitee Explore", + "domain": "gitee.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of projects (max 50)" + } + ], + "columns": [ + "name", + "description", + "stars", + "url" + ], + "type": "ts", + "modulePath": "gitee/trending.js", + "sourceFile": "gitee\\trending.ts" + }, + { + "site": "gitee", + "name": "user", + "description": "Show a Gitee user profile panel", + "domain": "gitee.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Gitee username" + } + ], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "gitee/user.js", + "sourceFile": "gitee\\user.ts" + }, + { + "site": "google", + "name": "search", + "description": "Search Google", + "domain": "google.com", + "strategy": "public", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results (1-100)" + }, + { + "name": "lang", + "type": "str", + "default": "en", + "required": false, + "help": "Language short code (e.g. en, zh)" + } + ], + "columns": [ + "type", + "title", + "url", + "snippet" + ], + "type": "ts", + "modulePath": "google/search.js", + "sourceFile": "google\\search.ts" + }, + { + "site": "google", + "name": "suggest", + "description": "Get Google search suggestions", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "lang", + "type": "str", + "default": "zh-CN", + "required": false, + "help": "Language code" + } + ], + "columns": [ + "suggestion" + ], + "type": "ts", + "modulePath": "google/suggest.js", + "sourceFile": "google\\suggest.ts" + }, + { + "site": "grok", + "name": "ask", + "description": "Send a message to Grok and get response", + "domain": "grok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "prompt", + "type": "string", + "required": true, + "positional": true, + "help": "Prompt to send to Grok" + }, + { + "name": "timeout", + "type": "int", + "default": 120, + "required": false, + "help": "Max seconds to wait for response (default: 120)" + }, + { + "name": "new", + "type": "boolean", + "default": false, + "required": false, + "help": "Start a new chat before sending (default: false)" + }, + { + "name": "web", + "type": "boolean", + "default": false, + "required": false, + "help": "Use the explicit grok.com consumer web flow (default: false)" + } + ], + "columns": [ + "response" + ], + "type": "ts", + "modulePath": "grok/ask.js", + "sourceFile": "grok\\ask.ts" + }, + { + "site": "hackernews", + "name": "ask", + "description": "Hacker News Ask HN posts", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "type": "ts", + "modulePath": "hackernews/ask.js", + "sourceFile": "hackernews\\ask.ts" + }, + { + "site": "hackernews", + "name": "best", + "description": "Hacker News best stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "type": "ts", + "modulePath": "hackernews/best.js", + "sourceFile": "hackernews\\best.ts" + }, + { + "site": "hackernews", + "name": "jobs", + "description": "Hacker News job postings", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of job postings" + } + ], + "columns": [ + "rank", + "title", + "author", + "url" + ], + "type": "ts", + "modulePath": "hackernews/jobs.js", + "sourceFile": "hackernews\\jobs.ts" + }, + { + "site": "hackernews", + "name": "new", + "description": "Hacker News newest stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "type": "ts", + "modulePath": "hackernews/new.js", + "sourceFile": "hackernews\\new.ts" + }, + { + "site": "hackernews", + "name": "search", + "description": "Search Hacker News stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + }, + { + "name": "sort", + "type": "str", + "default": "relevance", + "required": false, + "help": "Sort by relevance or date", + "choices": [ + "relevance", + "date" + ] + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "url" + ], + "type": "ts", + "modulePath": "hackernews/search.js", + "sourceFile": "hackernews\\search.ts" + }, + { + "site": "hackernews", + "name": "show", + "description": "Hacker News Show HN posts", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "type": "ts", + "modulePath": "hackernews/show.js", + "sourceFile": "hackernews\\show.ts" + }, + { + "site": "hackernews", + "name": "top", + "description": "Hacker News top stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "type": "ts", + "modulePath": "hackernews/top.js", + "sourceFile": "hackernews\\top.ts" + }, + { + "site": "hackernews", + "name": "user", + "description": "Hacker News user profile", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "HN username" + } + ], + "columns": [ + "username", + "karma", + "created", + "about" + ], + "type": "ts", + "modulePath": "hackernews/user.js", + "sourceFile": "hackernews\\user.ts" + }, + { + "site": "hf", + "name": "top", + "description": "Top upvoted Hugging Face papers", + "domain": "huggingface.co", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of papers" + }, + { + "name": "all", + "type": "bool", + "default": false, + "required": false, + "help": "Return all papers (ignore limit)" + }, + { + "name": "date", + "type": "str", + "required": false, + "help": "Date (YYYY-MM-DD), defaults to most recent" + }, + { + "name": "period", + "type": "str", + "default": "daily", + "required": false, + "help": "Time period: daily, weekly, or monthly", + "choices": [ + "daily", + "weekly", + "monthly" + ] + } + ], + "type": "ts", + "modulePath": "hf/top.js", + "sourceFile": "hf\\top.ts" + }, + { + "site": "hupu", + "name": "hot", + "description": "虎扑热门帖子", + "domain": "bbs.hupu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of hot posts" + } + ], + "columns": [ + "rank", + "title", + "url" + ], + "type": "ts", + "modulePath": "hupu/hot.js", + "sourceFile": "hupu\\hot.ts" + }, + { + "site": "instagram", + "name": "comment", + "description": "Comment on an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Comment text" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "text" + ], + "type": "ts", + "modulePath": "instagram/comment.js", + "sourceFile": "instagram\\comment.ts" + }, + { + "site": "instagram", + "name": "download", + "description": "Download images and videos from Instagram posts and reels", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram post / reel / tv URL" + }, + { + "name": "path", + "type": "str", + "default": "C:\\Users\\Administrator\\Downloads\\Instagram", + "required": false, + "help": "Download directory" + } + ], + "type": "ts", + "modulePath": "instagram/download.js", + "sourceFile": "instagram\\download.ts", + "navigateBefore": false + }, + { + "site": "instagram", + "name": "explore", + "description": "Instagram explore/discover trending posts", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "user", + "caption", + "likes", + "comments", + "type" + ], + "type": "ts", + "modulePath": "instagram/explore.js", + "sourceFile": "instagram\\explore.ts" + }, + { + "site": "instagram", + "name": "follow", + "description": "Follow an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username to follow" + } + ], + "columns": [ + "status", + "username" + ], + "type": "ts", + "modulePath": "instagram/follow.js", + "sourceFile": "instagram\\follow.ts" + }, + { + "site": "instagram", + "name": "followers", + "description": "List followers of an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of followers" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private" + ], + "type": "ts", + "modulePath": "instagram/followers.js", + "sourceFile": "instagram\\followers.ts" + }, + { + "site": "instagram", + "name": "following", + "description": "List accounts an Instagram user is following", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of accounts" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private" + ], + "type": "ts", + "modulePath": "instagram/following.js", + "sourceFile": "instagram\\following.ts" + }, + { + "site": "instagram", + "name": "like", + "description": "Like an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "type": "ts", + "modulePath": "instagram/like.js", + "sourceFile": "instagram\\like.ts" + }, + { + "site": "instagram", + "name": "note", + "description": "Publish a text Instagram note", + "domain": "www.instagram.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "content", + "type": "str", + "required": true, + "positional": true, + "help": "Note text (max 60 characters)" + } + ], + "columns": [ + "status", + "detail", + "noteId" + ], + "timeout": 120, + "type": "ts", + "modulePath": "instagram/note.js", + "sourceFile": "instagram\\note.ts" + }, + { + "site": "instagram", + "name": "profile", + "description": "Get Instagram user profile info", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + } + ], + "columns": [ + "username", + "name", + "followers", + "following", + "posts", + "verified", + "bio" + ], + "type": "ts", + "modulePath": "instagram/profile.js", + "sourceFile": "instagram\\profile.ts" + }, + { + "site": "instagram", + "name": "save", + "description": "Save (bookmark) an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "type": "ts", + "modulePath": "instagram/save.js", + "sourceFile": "instagram\\save.ts" + }, + { + "site": "instagram", + "name": "saved", + "description": "Get your saved Instagram posts", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of saved posts" + } + ], + "columns": [ + "index", + "user", + "caption", + "likes", + "comments", + "type" + ], + "type": "ts", + "modulePath": "instagram/saved.js", + "sourceFile": "instagram\\saved.ts" + }, + { + "site": "instagram", + "name": "search", + "description": "Search Instagram users", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private", + "url" + ], + "type": "ts", + "modulePath": "instagram/search.js", + "sourceFile": "instagram\\search.ts" + }, + { + "site": "instagram", + "name": "unfollow", + "description": "Unfollow an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username to unfollow" + } + ], + "columns": [ + "status", + "username" + ], + "type": "ts", + "modulePath": "instagram/unfollow.js", + "sourceFile": "instagram\\unfollow.ts" + }, + { + "site": "instagram", + "name": "unlike", + "description": "Unlike an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "type": "ts", + "modulePath": "instagram/unlike.js", + "sourceFile": "instagram\\unlike.ts" + }, + { + "site": "instagram", + "name": "unsave", + "description": "Unsave (remove bookmark) an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "type": "ts", + "modulePath": "instagram/unsave.js", + "sourceFile": "instagram\\unsave.ts" + }, + { + "site": "instagram", + "name": "user", + "description": "Get recent posts from an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 12, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "index", + "caption", + "likes", + "comments", + "type", + "date" + ], + "type": "ts", + "modulePath": "instagram/user.js", + "sourceFile": "instagram\\user.ts" + }, + { + "site": "jd", + "name": "cart", + "description": "查看京东购物车", + "domain": "cart.jd.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "index", + "title", + "price", + "quantity", + "sku" + ], + "type": "ts", + "modulePath": "jd/cart.js", + "sourceFile": "jd\\cart.ts", + "navigateBefore": false + }, + { + "site": "jd", + "name": "item", + "description": "京东商品详情(价格、店铺、规格参数、AVIF 图片)", + "domain": "item.jd.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "sku", + "type": "str", + "required": true, + "positional": true, + "help": "商品 SKU ID(如 100291143898)" + }, + { + "name": "images", + "type": "int", + "default": 10, + "required": false, + "help": "AVIF 图片数量上限(默认10)" + } + ], + "columns": [ + "title", + "price", + "shop", + "specs", + "avifImages" + ], + "type": "ts", + "modulePath": "jd/item.js", + "sourceFile": "jd\\item.ts" + }, + { + "site": "jike", + "name": "comment", + "description": "评论即刻帖子", + "domain": "web.okjike.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "帖子 ID" + }, + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "评论内容" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "jike/comment.js", + "sourceFile": "jike\\comment.ts" + }, + { + "site": "jike", + "name": "create", + "description": "发布即刻动态", + "domain": "web.okjike.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "动态正文内容" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "jike/create.js", + "sourceFile": "jike\\create.ts" + }, + { + "site": "jike", + "name": "like", + "description": "点赞即刻帖子", + "domain": "web.okjike.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "帖子 ID" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "jike/like.js", + "sourceFile": "jike\\like.ts" + }, + { + "site": "jike", + "name": "notifications", + "description": "即刻通知", + "domain": "web.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "type", + "user", + "content", + "time" + ], + "type": "ts", + "modulePath": "jike/notifications.js", + "sourceFile": "jike\\notifications.ts" + }, + { + "site": "jike", + "name": "post", + "description": "即刻帖子详情及评论", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (from post URL)" + } + ], + "columns": [ + "type", + "author", + "content", + "likes", + "time" + ], + "type": "ts", + "modulePath": "jike/post.js", + "sourceFile": "jike\\post.ts" + }, + { + "site": "jike", + "name": "repost", + "description": "转发即刻帖子", + "domain": "web.okjike.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "帖子 ID" + }, + { + "name": "text", + "type": "string", + "required": false, + "positional": true, + "help": "转发附言(可选)" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "jike/repost.js", + "sourceFile": "jike\\repost.ts" + }, + { + "site": "jike", + "name": "topic", + "description": "即刻话题/圈子帖子", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "Topic ID (from topic URL, e.g. 553870e8e4b0cafb0a1bef68)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "content", + "author", + "likes", + "comments", + "time", + "url" + ], + "type": "ts", + "modulePath": "jike/topic.js", + "sourceFile": "jike\\topic.ts" + }, + { + "site": "jike", + "name": "user", + "description": "即刻用户动态", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Username from profile URL (e.g. wenhao1996)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "content", + "type", + "likes", + "comments", + "time", + "url" + ], + "type": "ts", + "modulePath": "jike/user.js", + "sourceFile": "jike\\user.ts" + }, + { + "site": "jimeng", + "name": "generate", + "description": "即梦AI 文生图 — 输入 prompt 生成图片", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "prompt", + "type": "string", + "required": true, + "positional": true, + "help": "图片描述 prompt" + }, + { + "name": "model", + "type": "string", + "default": "high_aes_general_v50", + "required": false, + "help": "模型: high_aes_general_v50 (5.0 Lite), high_aes_general_v42 (4.6), high_aes_general_v40 (4.0)" + }, + { + "name": "wait", + "type": "int", + "default": 40, + "required": false, + "help": "等待生成完成的秒数" + } + ], + "columns": [ + "status", + "prompt", + "image_count", + "image_urls" + ], + "type": "ts", + "modulePath": "jimeng/generate.js", + "sourceFile": "jimeng\\generate.ts" + }, + { + "site": "jimeng", + "name": "history", + "description": "即梦AI 查看最近生成的作品", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 5, + "required": false, + "help": "" + } + ], + "columns": [ + "prompt", + "model", + "status", + "image_url", + "created_at" + ], + "type": "ts", + "modulePath": "jimeng/history.js", + "sourceFile": "jimeng\\history.ts" + }, + { + "site": "jimeng", + "name": "new", + "description": "即梦AI 新建会话(workspace)", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "workspace_id", + "workspace_url" + ], + "type": "ts", + "modulePath": "jimeng/new.js", + "sourceFile": "jimeng\\new.ts" + }, + { + "site": "jimeng", + "name": "workspaces", + "description": "即梦AI 查看所有工作区(会话窗口)", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "workspace_id", + "name", + "is_pinned", + "updated_at" + ], + "type": "ts", + "modulePath": "jimeng/workspaces.js", + "sourceFile": "jimeng\\workspaces.ts" + }, + { + "site": "linkedin", + "name": "search", + "description": "Search LinkedIn jobs", + "domain": "www.linkedin.com", + "strategy": "header", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Job search keywords" + }, + { + "name": "location", + "type": "string", + "required": false, + "help": "Location text such as San Francisco Bay Area" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of jobs to return (max 100)" + }, + { + "name": "start", + "type": "int", + "default": 0, + "required": false, + "help": "Result offset for pagination" + }, + { + "name": "details", + "type": "bool", + "default": false, + "required": false, + "help": "Include full job description and apply URL (slower)" + }, + { + "name": "company", + "type": "string", + "required": false, + "help": "Comma-separated company names or LinkedIn company IDs" + }, + { + "name": "experience-level", + "type": "string", + "required": false, + "help": "Comma-separated: internship, entry, associate, mid-senior, director, executive" + }, + { + "name": "job-type", + "type": "string", + "required": false, + "help": "Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other" + }, + { + "name": "date-posted", + "type": "string", + "required": false, + "help": "One of: any, month, week, 24h" + }, + { + "name": "remote", + "type": "string", + "required": false, + "help": "Comma-separated: on-site, hybrid, remote" + } + ], + "columns": [ + "rank", + "title", + "company", + "location", + "listed", + "salary", + "url" + ], + "type": "ts", + "modulePath": "linkedin/search.js", + "sourceFile": "linkedin\\search.ts" + }, + { + "site": "linkedin", + "name": "timeline", + "description": "Read LinkedIn home timeline posts", + "domain": "www.linkedin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts to return (max 100)" + } + ], + "columns": [ + "rank", + "author", + "author_url", + "headline", + "text", + "posted_at", + "reactions", + "comments", + "url" + ], + "type": "ts", + "modulePath": "linkedin/timeline.js", + "sourceFile": "linkedin\\timeline.ts" + }, + { + "site": "linux-do", + "name": "feed", + "description": "linux.do 话题列表(需登录;支持全站、标签、分类)", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "view", + "type": "str", + "default": "latest", + "required": false, + "help": "View type", + "choices": [ + "latest", + "hot", + "top" + ] + }, + { + "name": "tag", + "type": "str", + "required": false, + "help": "Tag name, slug, or id" + }, + { + "name": "category", + "type": "str", + "required": false, + "help": "Category name, slug, id, or parent/name path" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of items (per_page)" + }, + { + "name": "order", + "type": "str", + "default": "default", + "required": false, + "help": "Sort order", + "choices": [ + "default", + "created", + "activity", + "views", + "posts", + "category", + "likes", + "op_likes", + "posters" + ] + }, + { + "name": "ascending", + "type": "boolean", + "default": false, + "required": false, + "help": "Sort ascending (default: desc)" + }, + { + "name": "period", + "type": "str", + "required": false, + "help": "Time period (only for --view top)", + "choices": [ + "all", + "daily", + "weekly", + "monthly", + "quarterly", + "yearly" + ] + } + ], + "columns": [ + "title", + "replies", + "created", + "likes", + "views", + "url" + ], + "type": "ts", + "modulePath": "linux-do/feed.js", + "sourceFile": "linux-do\\feed.ts" + }, + { + "site": "linux-do", + "name": "topic-content", + "description": "Get the main topic body as Markdown", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "int", + "required": true, + "positional": true, + "help": "Topic ID" + } + ], + "columns": [ + "content" + ], + "type": "ts", + "modulePath": "linux-do/topic-content.js", + "sourceFile": "linux-do\\topic-content.ts" + }, + { + "site": "lobsters", + "name": "active", + "description": "Lobste.rs most active discussions", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "lobsters/active.js", + "sourceFile": "lobsters\\active.ts" + }, + { + "site": "lobsters", + "name": "hot", + "description": "Lobste.rs hottest stories", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "lobsters/hot.js", + "sourceFile": "lobsters\\hot.ts" + }, + { + "site": "lobsters", + "name": "newest", + "description": "Lobste.rs newest stories", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "lobsters/newest.js", + "sourceFile": "lobsters\\newest.ts" + }, + { + "site": "lobsters", + "name": "tag", + "description": "Lobste.rs stories by tag", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "tag", + "type": "str", + "required": true, + "positional": true, + "help": "Tag name (e.g. programming, rust, security, ai)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "type": "ts", + "modulePath": "lobsters/tag.js", + "sourceFile": "lobsters\\tag.ts" + }, + { + "site": "notion", + "name": "export", + "description": "Export the current Notion page as Markdown", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file (default: /tmp/notion-export.md)" + } + ], + "columns": [ + "Status", + "File" + ], + "type": "ts", + "modulePath": "notion/export.js", + "sourceFile": "notion\\export.ts" + }, + { + "site": "notion", + "name": "favorites", + "description": "List pages from the Notion Favorites section in the sidebar", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Title", + "Icon" + ], + "type": "ts", + "modulePath": "notion/favorites.js", + "sourceFile": "notion\\favorites.ts" + }, + { + "site": "notion", + "name": "new", + "description": "Create a new page in Notion", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "title", + "type": "str", + "required": false, + "positional": true, + "help": "Page title (optional)" + } + ], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "notion/new.js", + "sourceFile": "notion\\new.ts" + }, + { + "site": "notion", + "name": "read", + "description": "Read the content of the currently open Notion page", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Title", + "Content" + ], + "type": "ts", + "modulePath": "notion/read.js", + "sourceFile": "notion\\read.ts" + }, + { + "site": "notion", + "name": "search", + "description": "Search pages and databases in Notion via Quick Find (Cmd+P)", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + } + ], + "columns": [ + "Index", + "Title" + ], + "type": "ts", + "modulePath": "notion/search.js", + "sourceFile": "notion\\search.ts" + }, + { + "site": "notion", + "name": "sidebar", + "description": "List pages and databases from the Notion sidebar", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Title" + ], + "type": "ts", + "modulePath": "notion/sidebar.js", + "sourceFile": "notion\\sidebar.ts" + }, + { + "site": "notion", + "name": "status", + "description": "Check active CDP connection to Notion Desktop", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Url", + "Title" + ], + "type": "ts", + "modulePath": "notion/status.js", + "sourceFile": "notion\\status.ts" + }, + { + "site": "notion", + "name": "write", + "description": "Append text content to the currently open Notion page", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text to append to the page" + } + ], + "columns": [ + "Status" + ], + "type": "ts", + "modulePath": "notion/write.js", + "sourceFile": "notion\\write.ts" + }, + { + "site": "pixiv", + "name": "detail", + "description": "View illustration details (tags, stats, URLs)", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Illustration ID" + } + ], + "columns": [ + "illust_id", + "title", + "author", + "type", + "pages", + "bookmarks", + "likes", + "views", + "tags", + "created", + "url" + ], + "type": "ts", + "modulePath": "pixiv/detail.js", + "sourceFile": "pixiv\\detail.ts" + }, + { + "site": "pixiv", + "name": "ranking", + "description": "Pixiv illustration rankings (daily/weekly/monthly)", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "mode", + "type": "str", + "default": "daily", + "required": false, + "help": "Ranking mode", + "choices": [ + "daily", + "weekly", + "monthly", + "rookie", + "original", + "male", + "female", + "daily_r18", + "weekly_r18" + ] + }, + { + "name": "page", + "type": "int", + "default": 1, + "required": false, + "help": "Page number" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "author", + "illust_id", + "pages", + "bookmarks" + ], + "type": "ts", + "modulePath": "pixiv/ranking.js", + "sourceFile": "pixiv\\ranking.ts" + }, + { + "site": "pixiv", + "name": "user", + "description": "View Pixiv artist profile", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "uid", + "type": "str", + "required": true, + "positional": true, + "help": "Pixiv user ID" + } + ], + "columns": [ + "user_id", + "name", + "premium", + "following", + "illusts", + "manga", + "novels", + "comment" + ], + "type": "ts", + "modulePath": "pixiv/user.js", + "sourceFile": "pixiv\\user.ts" + }, + { + "site": "reddit", + "name": "comment", + "description": "Post a comment on a Reddit post", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "post-id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" + }, + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "Comment text" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "reddit/comment.js", + "sourceFile": "reddit\\comment.ts" + }, + { + "site": "reddit", + "name": "frontpage", + "description": "Reddit Frontpage / r/all", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "author", + "upvotes", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/frontpage.js", + "sourceFile": "reddit\\frontpage.ts" + }, + { + "site": "reddit", + "name": "hot", + "description": "Reddit 热门帖子", + "domain": "www.reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "subreddit", + "type": "str", + "default": "", + "required": false, + "help": "Subreddit name (e.g. programming). Empty for frontpage" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "title", + "subreddit", + "score", + "comments" + ], + "type": "ts", + "modulePath": "reddit/hot.js", + "sourceFile": "reddit\\hot.ts" + }, + { + "site": "reddit", + "name": "popular", + "description": "Reddit Popular posts (/r/popular)", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "rank", + "title", + "subreddit", + "score", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/popular.js", + "sourceFile": "reddit\\popular.ts" + }, + { + "site": "reddit", + "name": "read", + "description": "Read a Reddit post and its comments", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "post-id", + "type": "str", + "required": true, + "positional": true, + "help": "Post ID (e.g. 1abc123) or full URL" + }, + { + "name": "sort", + "type": "str", + "default": "best", + "required": false, + "help": "Comment sort: best, top, new, controversial, old, qa" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of top-level comments" + }, + { + "name": "depth", + "type": "int", + "default": 2, + "required": false, + "help": "Max reply depth (1=no replies, 2=one level of replies, etc.)" + }, + { + "name": "replies", + "type": "int", + "default": 5, + "required": false, + "help": "Max replies shown per comment at each level (sorted by score)" + }, + { + "name": "max-length", + "type": "int", + "default": 2000, + "required": false, + "help": "Max characters per comment body (min 100)" + } + ], + "columns": [ + "type", + "author", + "score", + "text" + ], + "type": "ts", + "modulePath": "reddit/read.js", + "sourceFile": "reddit\\read.ts" + }, + { + "site": "reddit", + "name": "save", + "description": "Save or unsave a Reddit post", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "post-id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" + }, + { + "name": "undo", + "type": "boolean", + "default": false, + "required": false, + "help": "Unsave instead of save" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "reddit/save.js", + "sourceFile": "reddit\\save.ts" + }, + { + "site": "reddit", + "name": "saved", + "description": "Browse your saved Reddit posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "score", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/saved.js", + "sourceFile": "reddit\\saved.ts" + }, + { + "site": "reddit", + "name": "search", + "description": "Search Reddit Posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "subreddit", + "type": "string", + "default": "", + "required": false, + "help": "Search within a specific subreddit" + }, + { + "name": "sort", + "type": "string", + "default": "relevance", + "required": false, + "help": "Sort order: relevance, hot, top, new, comments" + }, + { + "name": "time", + "type": "string", + "default": "all", + "required": false, + "help": "Time filter: hour, day, week, month, year, all" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "author", + "score", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/search.js", + "sourceFile": "reddit\\search.ts" + }, + { + "site": "reddit", + "name": "subreddit", + "description": "Get posts from a specific Subreddit", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "sort", + "type": "string", + "default": "hot", + "required": false, + "help": "Sorting method: hot, new, top, rising, controversial" + }, + { + "name": "time", + "type": "string", + "default": "all", + "required": false, + "help": "Time filter for top/controversial: hour, day, week, month, year, all" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "author", + "upvotes", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/subreddit.js", + "sourceFile": "reddit\\subreddit.ts" + }, + { + "site": "reddit", + "name": "subscribe", + "description": "Subscribe or unsubscribe to a subreddit", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "subreddit", + "type": "string", + "required": true, + "positional": true, + "help": "Subreddit name (e.g. python)" + }, + { + "name": "undo", + "type": "boolean", + "default": false, + "required": false, + "help": "Unsubscribe instead of subscribe" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "reddit/subscribe.js", + "sourceFile": "reddit\\subscribe.ts" + }, + { + "site": "reddit", + "name": "upvote", + "description": "Upvote or downvote a Reddit post", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "post-id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" + }, + { + "name": "direction", + "type": "string", + "default": "up", + "required": false, + "help": "Vote direction: up, down, none" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "reddit/upvote.js", + "sourceFile": "reddit\\upvote.ts" + }, + { + "site": "reddit", + "name": "upvoted", + "description": "Browse your upvoted Reddit posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "score", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/upvoted.js", + "sourceFile": "reddit\\upvoted.ts" + }, + { + "site": "reddit", + "name": "user", + "description": "View a Reddit user profile", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + } + ], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "reddit/user.js", + "sourceFile": "reddit\\user.ts" + }, + { + "site": "reddit", + "name": "user-comments", + "description": "View a Reddit user's comment history", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "subreddit", + "score", + "body", + "url" + ], + "type": "ts", + "modulePath": "reddit/user-comments.js", + "sourceFile": "reddit\\user-comments.ts" + }, + { + "site": "reddit", + "name": "user-posts", + "description": "View a Reddit user's submitted posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "score", + "comments", + "url" + ], + "type": "ts", + "modulePath": "reddit/user-posts.js", + "sourceFile": "reddit\\user-posts.ts" + }, + { + "site": "reuters", + "name": "search", + "description": "Reuters 路透社新闻搜索", + "domain": "www.reuters.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results (max 40)" + } + ], + "columns": [ + "rank", + "title", + "date", + "section", + "url" + ], + "type": "ts", + "modulePath": "reuters/search.js", + "sourceFile": "reuters\\search.ts" + }, + { + "site": "sinablog", + "name": "search", + "description": "搜索新浪博客文章(通过新浪搜索)", + "domain": "blog.sina.com.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "搜索关键词" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "返回的文章数量" + } + ], + "columns": [ + "rank", + "title", + "author", + "date", + "description", + "url" + ], + "type": "ts", + "modulePath": "sinablog/search.js", + "sourceFile": "sinablog\\search.ts" + }, + { + "site": "sinafinance", + "name": "news", + "description": "新浪财经 7x24 小时实时快讯", + "domain": "app.cj.sina.com.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results (max 50)" + }, + { + "name": "type", + "type": "int", + "default": 0, + "required": false, + "help": "News type: 0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它" + } + ], + "columns": [ + "id", + "time", + "content", + "views" + ], + "type": "ts", + "modulePath": "sinafinance/news.js", + "sourceFile": "sinafinance\\news.ts" + }, + { + "site": "sinafinance", + "name": "rolling-news", + "description": "新浪财经滚动新闻", + "domain": "finance.sina.com.cn/roll", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "column", + "title", + "date", + "url" + ], + "type": "ts", + "modulePath": "sinafinance/rolling-news.js", + "sourceFile": "sinafinance\\rolling-news.ts" + }, + { + "site": "sinafinance", + "name": "stock", + "description": "新浪财经行情(A股/港股/美股)", + "domain": "suggest3.sinajs.cn,hq.sinajs.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "key", + "type": "string", + "required": true, + "positional": true, + "help": "Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)" + }, + { + "name": "market", + "type": "string", + "default": "auto", + "required": false, + "help": "Market: cn, hk, us, auto (default: auto searches cn → hk → us)" + } + ], + "columns": [ + "Symbol", + "Name", + "Price", + "Change", + "ChangePercent", + "Open", + "High", + "Low", + "Volume", + "MarketCap" + ], + "type": "ts", + "modulePath": "sinafinance/stock.js", + "sourceFile": "sinafinance\\stock.ts" + }, + { + "site": "sinafinance", + "name": "stock-rank", + "description": "新浪财经热搜榜", + "domain": "finance.sina.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "market", + "type": "string", + "default": "cn", + "required": false, + "help": "Market: cn (A股), hk (港股), us (美股), wh (外汇), ft (期货)", + "choices": [ + "cn", + "hk", + "us", + "wh", + "ft" + ] + } + ], + "columns": [ + "rank", + "name", + "symbol", + "market", + "price", + "change", + "url" + ], + "type": "ts", + "modulePath": "sinafinance/stock-rank.js", + "sourceFile": "sinafinance\\stock-rank.ts", + "navigateBefore": false + }, + { + "site": "smzdm", + "name": "search", + "description": "什么值得买搜索好价", + "domain": "www.smzdm.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "price", + "mall", + "comments", + "url" + ], + "type": "ts", + "modulePath": "smzdm/search.js", + "sourceFile": "smzdm\\search.ts" + }, + { + "site": "stackoverflow", + "name": "bounties", + "description": "Active bounties on Stack Overflow", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Max number of results" + } + ], + "columns": [ + "bounty", + "title", + "score", + "answers", + "url" + ], + "type": "ts", + "modulePath": "stackoverflow/bounties.js", + "sourceFile": "stackoverflow\\bounties.ts" + }, + { + "site": "stackoverflow", + "name": "hot", + "description": "Hot Stack Overflow questions", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "type": "ts", + "modulePath": "stackoverflow/hot.js", + "sourceFile": "stackoverflow\\hot.ts" + }, + { + "site": "stackoverflow", + "name": "search", + "description": "Search Stack Overflow questions", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "type": "ts", + "modulePath": "stackoverflow/search.js", + "sourceFile": "stackoverflow\\search.ts" + }, + { + "site": "stackoverflow", + "name": "unanswered", + "description": "Top voted unanswered questions on Stack Overflow", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "type": "ts", + "modulePath": "stackoverflow/unanswered.js", + "sourceFile": "stackoverflow\\unanswered.ts" + }, + { + "site": "steam", + "name": "top-sellers", + "description": "Steam top selling games", + "domain": "store.steampowered.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of games" + } + ], + "columns": [ + "rank", + "name", + "price", + "discount", + "url" + ], + "type": "ts", + "modulePath": "steam/top-sellers.js", + "sourceFile": "steam\\top-sellers.ts" + }, + { + "site": "substack", + "name": "search", + "description": "搜索 Substack 文章和 Newsletter", + "domain": "substack.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "搜索关键词" + }, + { + "name": "type", + "type": "str", + "default": "posts", + "required": false, + "help": "搜索类型(posts=文章, publications=Newsletter)", + "choices": [ + "posts", + "publications" + ] + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "返回结果数量" + } + ], + "columns": [ + "rank", + "title", + "author", + "date", + "description", + "url" + ], + "type": "ts", + "modulePath": "substack/search.js", + "sourceFile": "substack\\search.ts" + }, + { + "site": "tiktok", + "name": "comment", + "description": "Comment on a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + }, + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Comment text" + } + ], + "columns": [ + "status", + "url", + "text" + ], + "type": "ts", + "modulePath": "tiktok/comment.js", + "sourceFile": "tiktok\\comment.ts" + }, + { + "site": "tiktok", + "name": "explore", + "description": "Get trending TikTok videos from explore page", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of videos" + } + ], + "columns": [ + "rank", + "author", + "views", + "url" + ], + "type": "ts", + "modulePath": "tiktok/explore.js", + "sourceFile": "tiktok\\explore.ts" + }, + { + "site": "tiktok", + "name": "follow", + "description": "Follow a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "status", + "username" + ], + "type": "ts", + "modulePath": "tiktok/follow.js", + "sourceFile": "tiktok\\follow.ts" + }, + { + "site": "tiktok", + "name": "following", + "description": "List accounts you follow on TikTok", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of accounts" + } + ], + "columns": [ + "index", + "username", + "name" + ], + "type": "ts", + "modulePath": "tiktok/following.js", + "sourceFile": "tiktok\\following.ts" + }, + { + "site": "tiktok", + "name": "friends", + "description": "Get TikTok friend suggestions", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of suggestions" + } + ], + "columns": [ + "index", + "username", + "name" + ], + "type": "ts", + "modulePath": "tiktok/friends.js", + "sourceFile": "tiktok\\friends.ts" + }, + { + "site": "tiktok", + "name": "like", + "description": "Like a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "likes", + "url" + ], + "type": "ts", + "modulePath": "tiktok/like.js", + "sourceFile": "tiktok\\like.ts" + }, + { + "site": "tiktok", + "name": "live", + "description": "Browse live streams on TikTok", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of streams" + } + ], + "columns": [ + "index", + "streamer", + "viewers", + "url" + ], + "type": "ts", + "modulePath": "tiktok/live.js", + "sourceFile": "tiktok\\live.ts" + }, + { + "site": "tiktok", + "name": "notifications", + "description": "Get TikTok notifications (likes, comments, mentions, followers)", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "Number of notifications" + }, + { + "name": "type", + "type": "str", + "default": "all", + "required": false, + "help": "Notification type", + "choices": [ + "all", + "likes", + "comments", + "mentions", + "followers" + ] + } + ], + "columns": [ + "index", + "text" + ], + "type": "ts", + "modulePath": "tiktok/notifications.js", + "sourceFile": "tiktok\\notifications.ts" + }, + { + "site": "tiktok", + "name": "profile", + "description": "Get TikTok user profile info", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "username", + "name", + "followers", + "following", + "likes", + "videos", + "verified", + "bio" + ], + "type": "ts", + "modulePath": "tiktok/profile.js", + "sourceFile": "tiktok\\profile.ts" + }, + { + "site": "tiktok", + "name": "save", + "description": "Add a TikTok video to Favorites", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "url" + ], + "type": "ts", + "modulePath": "tiktok/save.js", + "sourceFile": "tiktok\\save.ts" + }, + { + "site": "tiktok", + "name": "search", + "description": "Search TikTok videos", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "desc", + "author", + "url", + "plays", + "likes", + "comments", + "shares" + ], + "type": "ts", + "modulePath": "tiktok/search.js", + "sourceFile": "tiktok\\search.ts" + }, + { + "site": "tiktok", + "name": "unfollow", + "description": "Unfollow a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "status", + "username" + ], + "type": "ts", + "modulePath": "tiktok/unfollow.js", + "sourceFile": "tiktok\\unfollow.ts" + }, + { + "site": "tiktok", + "name": "unlike", + "description": "Unlike a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "likes", + "url" + ], + "type": "ts", + "modulePath": "tiktok/unlike.js", + "sourceFile": "tiktok\\unlike.ts" + }, + { + "site": "tiktok", + "name": "unsave", + "description": "Remove a TikTok video from Favorites", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "url" + ], + "type": "ts", + "modulePath": "tiktok/unsave.js", + "sourceFile": "tiktok\\unsave.ts" + }, + { + "site": "tiktok", + "name": "user", + "description": "Get recent videos from a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of videos" + } + ], + "columns": [ + "index", + "views", + "url" + ], + "type": "ts", + "modulePath": "tiktok/user.js", + "sourceFile": "tiktok\\user.ts" + }, + { + "site": "twitter", + "name": "accept", + "description": "Auto-accept DM requests containing specific keywords", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Keywords to match (comma-separated for OR, e.g. \"群,微信\")" + }, + { + "name": "max", + "type": "int", + "default": 20, + "required": false, + "help": "Maximum number of requests to accept (default: 20)" + } + ], + "columns": [ + "index", + "status", + "user", + "message" + ], + "timeout": 600, + "type": "ts", + "modulePath": "twitter/accept.js", + "sourceFile": "twitter\\accept.ts" + }, + { + "site": "twitter", + "name": "block", + "description": "Block a Twitter user", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Twitter screen name (without @)" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/block.js", + "sourceFile": "twitter\\block.ts" + }, + { + "site": "twitter", + "name": "bookmark", + "description": "Bookmark a tweet", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "Tweet URL to bookmark" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/bookmark.js", + "sourceFile": "twitter\\bookmark.ts" + }, + { + "site": "twitter", + "name": "bookmarks", + "description": "Fetch Twitter/X bookmarks", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "author", + "text", + "likes", + "url" + ], + "type": "ts", + "modulePath": "twitter/bookmarks.js", + "sourceFile": "twitter\\bookmarks.ts" + }, + { + "site": "twitter", + "name": "delete", + "description": "Delete a specific tweet by URL", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "The URL of the tweet to delete" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/delete.js", + "sourceFile": "twitter\\delete.ts" + }, + { + "site": "twitter", + "name": "download", + "description": "下载 Twitter/X 媒体(图片和视频)", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": false, + "positional": true, + "help": "Twitter username (downloads from media tab)" + }, + { + "name": "tweet-url", + "type": "str", + "required": false, + "help": "Single tweet URL to download" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of tweets to scan" + }, + { + "name": "output", + "type": "str", + "default": "./twitter-downloads", + "required": false, + "help": "Output directory" + } + ], + "columns": [ + "index", + "type", + "status", + "size" + ], + "type": "ts", + "modulePath": "twitter/download.js", + "sourceFile": "twitter\\download.ts" + }, + { + "site": "twitter", + "name": "follow", + "description": "Follow a Twitter user", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Twitter screen name (without @)" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/follow.js", + "sourceFile": "twitter\\follow.ts" + }, + { + "site": "twitter", + "name": "followers", + "description": "Get accounts following a Twitter/X user", + "domain": "x.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "user", + "type": "string", + "required": false, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "screen_name", + "name", + "bio", + "followers" + ], + "type": "ts", + "modulePath": "twitter/followers.js", + "sourceFile": "twitter\\followers.ts" + }, + { + "site": "twitter", + "name": "following", + "description": "Get accounts a Twitter/X user is following", + "domain": "x.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "user", + "type": "string", + "required": false, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "screen_name", + "name", + "bio", + "followers" + ], + "type": "ts", + "modulePath": "twitter/following.js", + "sourceFile": "twitter\\following.ts" + }, + { + "site": "twitter", + "name": "hide-reply", + "description": "Hide a reply on your tweet (useful for hiding bot/spam replies)", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "The URL of the reply tweet to hide" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/hide-reply.js", + "sourceFile": "twitter\\hide-reply.ts" + }, + { + "site": "twitter", + "name": "like", + "description": "Like a specific tweet", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "The URL of the tweet to like" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/like.js", + "sourceFile": "twitter\\like.ts" + }, + { + "site": "twitter", + "name": "notifications", + "description": "Get Twitter/X notifications", + "domain": "x.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "id", + "action", + "author", + "text", + "url" + ], + "type": "ts", + "modulePath": "twitter/notifications.js", + "sourceFile": "twitter\\notifications.ts" + }, + { + "site": "twitter", + "name": "post", + "description": "Post a new tweet/thread", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "The text content of the tweet" + }, + { + "name": "images", + "type": "string", + "required": false, + "help": "Image paths, comma-separated, max 4 (jpg/png/gif/webp)" + } + ], + "columns": [ + "status", + "message", + "text" + ], + "type": "ts", + "modulePath": "twitter/post.js", + "sourceFile": "twitter\\post.ts" + }, + { + "site": "twitter", + "name": "reply", + "description": "Reply to a specific tweet, optionally with a local or remote image", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "The URL of the tweet to reply to" + }, + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "The text content of your reply" + }, + { + "name": "image", + "type": "str", + "required": false, + "help": "Optional local image path to attach to the reply" + }, + { + "name": "image-url", + "type": "str", + "required": false, + "help": "Optional remote image URL to download and attach to the reply" + } + ], + "columns": [ + "status", + "message", + "text" + ], + "type": "ts", + "modulePath": "twitter/reply.js", + "sourceFile": "twitter\\reply.ts" + }, + { + "site": "twitter", + "name": "reply-dm", + "description": "Send a message to recent DM conversations", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "Message text to send (e.g. \"我的微信 wxkabi\")" + }, + { + "name": "max", + "type": "int", + "default": 20, + "required": false, + "help": "Maximum number of conversations to reply to (default: 20)" + }, + { + "name": "skip-replied", + "type": "boolean", + "default": true, + "required": false, + "help": "Skip conversations where you already sent the same text (default: true)" + } + ], + "columns": [ + "index", + "status", + "user", + "message" + ], + "timeout": 600, + "type": "ts", + "modulePath": "twitter/reply-dm.js", + "sourceFile": "twitter\\reply-dm.ts" + }, + { + "site": "twitter", + "name": "search", + "description": "Search Twitter/X for tweets", + "domain": "x.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "filter", + "type": "string", + "default": "top", + "required": false, + "help": "", + "choices": [ + "top", + "live" + ] + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "help": "" + } + ], + "columns": [ + "id", + "author", + "text", + "created_at", + "likes", + "views", + "url" + ], + "type": "ts", + "modulePath": "twitter/search.js", + "sourceFile": "twitter\\search.ts" + }, + { + "site": "twitter", + "name": "thread", + "description": "Get a tweet thread (original + all replies)", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "tweet-id", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "id", + "author", + "text", + "likes", + "retweets", + "url" + ], + "type": "ts", + "modulePath": "twitter/thread.js", + "sourceFile": "twitter\\thread.ts" + }, + { + "site": "twitter", + "name": "trending", + "description": "Twitter/X trending topics", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of trends to show" + } + ], + "columns": [ + "rank", + "topic", + "tweets", + "category" + ], + "type": "ts", + "modulePath": "twitter/trending.js", + "sourceFile": "twitter\\trending.ts" + }, + { + "site": "twitter", + "name": "unblock", + "description": "Unblock a Twitter user", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Twitter screen name (without @)" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/unblock.js", + "sourceFile": "twitter\\unblock.ts" + }, + { + "site": "twitter", + "name": "unbookmark", + "description": "Remove a tweet from bookmarks", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "positional": true, + "help": "Tweet URL to unbookmark" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/unbookmark.js", + "sourceFile": "twitter\\unbookmark.ts" + }, + { + "site": "twitter", + "name": "unfollow", + "description": "Unfollow a Twitter user", + "domain": "x.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Twitter screen name (without @)" + } + ], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "twitter/unfollow.js", + "sourceFile": "twitter\\unfollow.ts" + }, + { + "site": "v2ex", + "name": "daily", + "description": "V2EX 每日签到并领取铜币", + "domain": "www.v2ex.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "status", + "message" + ], + "type": "ts", + "modulePath": "v2ex/daily.js", + "sourceFile": "v2ex\\daily.ts" + }, + { + "site": "v2ex", + "name": "hot", + "description": "V2EX 热门话题", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of topics" + } + ], + "columns": [ + "id", + "rank", + "title", + "node", + "replies", + "url" + ], + "type": "ts", + "modulePath": "v2ex/hot.js", + "sourceFile": "v2ex\\hot.ts" + }, + { + "site": "v2ex", + "name": "latest", + "description": "V2EX 最新话题", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of topics" + } + ], + "columns": [ + "id", + "rank", + "title", + "node", + "replies", + "url" + ], + "type": "ts", + "modulePath": "v2ex/latest.js", + "sourceFile": "v2ex\\latest.ts" + }, + { + "site": "v2ex", + "name": "me", + "description": "V2EX 获取个人资料 (余额/未读提醒)", + "domain": "www.v2ex.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "username", + "balance", + "unread_notifications", + "daily_reward_ready" + ], + "type": "ts", + "modulePath": "v2ex/me.js", + "sourceFile": "v2ex\\me.ts" + }, + { + "site": "v2ex", + "name": "member", + "description": "V2EX 用户资料", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + } + ], + "columns": [ + "username", + "tagline", + "website", + "github", + "twitter", + "location" + ], + "type": "ts", + "modulePath": "v2ex/member.js", + "sourceFile": "v2ex\\member.ts" + }, + { + "site": "v2ex", + "name": "node", + "description": "V2EX 节点话题列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "Node name (e.g. python, javascript, apple)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of topics (API returns max 20)" + } + ], + "columns": [ + "rank", + "title", + "author", + "replies", + "url" + ], + "type": "ts", + "modulePath": "v2ex/node.js", + "sourceFile": "v2ex\\node.ts" + }, + { + "site": "v2ex", + "name": "nodes", + "description": "V2EX 所有节点列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "help": "Number of nodes" + } + ], + "columns": [ + "rank", + "name", + "title", + "topics", + "stars" + ], + "type": "ts", + "modulePath": "v2ex/nodes.js", + "sourceFile": "v2ex\\nodes.ts" + }, + { + "site": "v2ex", + "name": "notifications", + "description": "V2EX 获取提醒 (回复/由于)", + "domain": "www.v2ex.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of notifications" + } + ], + "columns": [ + "type", + "content", + "time" + ], + "type": "ts", + "modulePath": "v2ex/notifications.js", + "sourceFile": "v2ex\\notifications.ts" + }, + { + "site": "v2ex", + "name": "replies", + "description": "V2EX 主题回复列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Topic ID" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of replies" + } + ], + "columns": [ + "floor", + "author", + "content" + ], + "type": "ts", + "modulePath": "v2ex/replies.js", + "sourceFile": "v2ex\\replies.ts" + }, + { + "site": "v2ex", + "name": "topic", + "description": "V2EX 主题详情和回复", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Topic ID" + } + ], + "columns": [ + "id", + "title", + "content", + "member", + "created", + "node", + "replies", + "url" + ], + "type": "ts", + "modulePath": "v2ex/topic.js", + "sourceFile": "v2ex\\topic.ts" + }, + { + "site": "v2ex", + "name": "user", + "description": "V2EX 用户发帖列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of topics (API returns max 20)" + } + ], + "columns": [ + "rank", + "title", + "node", + "replies", + "url" + ], + "type": "ts", + "modulePath": "v2ex/user.js", + "sourceFile": "v2ex\\user.ts" + }, + { + "site": "web", + "name": "read", + "description": "Fetch any web page and export as Markdown", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "help": "Any web page URL" + }, + { + "name": "output", + "type": "str", + "default": "./web-articles", + "required": false, + "help": "Output directory" + }, + { + "name": "download-images", + "type": "boolean", + "default": true, + "required": false, + "help": "Download images locally" + }, + { + "name": "wait", + "type": "int", + "default": 3, + "required": false, + "help": "Seconds to wait after page load" + } + ], + "columns": [ + "title", + "author", + "publish_time", + "status", + "size" + ], + "type": "ts", + "modulePath": "web/read.js", + "sourceFile": "web\\read.ts", + "navigateBefore": false + }, + { + "site": "weibo", + "name": "comments", + "description": "Get comments on a Weibo post", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Post ID (numeric idstr)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of comments (max 50)" + } + ], + "columns": [ + "rank", + "author", + "text", + "likes", + "replies", + "time" + ], + "type": "ts", + "modulePath": "weibo/comments.js", + "sourceFile": "weibo\\comments.ts" + }, + { + "site": "weibo", + "name": "hot", + "description": "微博热搜", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "help": "Number of items (max 50)" + } + ], + "columns": [ + "rank", + "word", + "hot_value", + "category", + "label", + "url" + ], + "type": "ts", + "modulePath": "weibo/hot.js", + "sourceFile": "weibo\\hot.ts" + }, + { + "site": "weibo", + "name": "post", + "description": "Get a single Weibo post", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Post ID (numeric idstr or mblogid from URL)" + } + ], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "weibo/post.js", + "sourceFile": "weibo\\post.ts" + }, + { + "site": "weibo", + "name": "search", + "description": "搜索微博", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results (max 50)" + } + ], + "columns": [ + "rank", + "title", + "author", + "time", + "url" + ], + "type": "ts", + "modulePath": "weibo/search.js", + "sourceFile": "weibo\\search.ts" + }, + { + "site": "weibo", + "name": "user", + "description": "Get Weibo user profile", + "domain": "weibo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "User ID (numeric uid) or screen name" + } + ], + "columns": [ + "screen_name", + "uid", + "followers", + "following", + "statuses", + "verified", + "description", + "location", + "url" + ], + "type": "ts", + "modulePath": "weibo/user.js", + "sourceFile": "weibo\\user.ts" + }, + { + "site": "weixin", + "name": "download", + "description": "下载微信公众号文章为 Markdown 格式", + "domain": "mp.weixin.qq.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "help": "WeChat article URL (mp.weixin.qq.com/s/xxx)" + }, + { + "name": "output", + "type": "str", + "default": "./weixin-articles", + "required": false, + "help": "Output directory" + }, + { + "name": "download-images", + "type": "boolean", + "default": true, + "required": false, + "help": "Download images locally" + } + ], + "columns": [ + "title", + "author", + "publish_time", + "status", + "size" + ], + "type": "ts", + "modulePath": "weixin/download.js", + "sourceFile": "weixin\\download.ts" + }, + { + "site": "xianyu", + "name": "search", + "description": "搜索闲鱼商品", + "domain": "www.goofish.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results to return" + } + ], + "columns": [ + "item_id", + "rank", + "title", + "price", + "condition", + "brand", + "location", + "badge", + "url" + ], + "type": "ts", + "modulePath": "xianyu/search.js", + "sourceFile": "xianyu\\search.ts", + "navigateBefore": false + }, + { + "site": "xiaoe", + "name": "catalog", + "description": "小鹅通课程目录(支持普通课程、专栏、大专栏)", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "课程页面 URL" + } + ], + "columns": [ + "ch", + "chapter", + "no", + "title", + "type", + "resource_id", + "status" + ], + "type": "ts", + "modulePath": "xiaoe/catalog.js", + "sourceFile": "xiaoe\\catalog.ts" + }, + { + "site": "xiaoe", + "name": "content", + "description": "提取小鹅通图文页面内容为文本", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "页面 URL" + } + ], + "columns": [ + "title", + "content_length", + "image_count" + ], + "type": "ts", + "modulePath": "xiaoe/content.js", + "sourceFile": "xiaoe\\content.ts" + }, + { + "site": "xiaoe", + "name": "courses", + "description": "列出已购小鹅通课程(含 URL 和店铺名)", + "domain": "study.xiaoe-tech.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "title", + "shop", + "url" + ], + "type": "ts", + "modulePath": "xiaoe/courses.js", + "sourceFile": "xiaoe\\courses.ts" + }, + { + "site": "xiaoe", + "name": "detail", + "description": "小鹅通课程详情(名称、价格、学员数、店铺)", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "课程页面 URL" + } + ], + "columns": [ + "name", + "price", + "original_price", + "user_count", + "shop_name" + ], + "type": "ts", + "modulePath": "xiaoe/detail.js", + "sourceFile": "xiaoe\\detail.ts" + }, + { + "site": "xiaoe", + "name": "play-url", + "description": "小鹅通视频/音频/直播回放 M3U8 播放地址", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "小节页面 URL" + } + ], + "columns": [ + "title", + "resource_id", + "m3u8_url", + "duration_sec", + "method" + ], + "type": "ts", + "modulePath": "xiaoe/play-url.js", + "sourceFile": "xiaoe\\play-url.ts" + }, + { + "site": "xiaohongshu", + "name": "creator-note-detail", + "description": "小红书单篇笔记详情页数据 (笔记信息 + 核心/互动数据 + 观看来源 + 观众画像 + 趋势数据)", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "note-id", + "type": "string", + "required": true, + "positional": true, + "help": "Note ID (from creator-notes or note-detail page URL)" + } + ], + "columns": [ + "section", + "metric", + "value", + "extra" + ], + "type": "ts", + "modulePath": "xiaohongshu/creator-note-detail.js", + "sourceFile": "xiaohongshu\\creator-note-detail.ts" + }, + { + "site": "xiaohongshu", + "name": "creator-notes", + "description": "小红书创作者笔记列表 + 每篇数据 (标题/日期/观看/点赞/收藏/评论)", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of notes to return" + } + ], + "columns": [ + "rank", + "id", + "title", + "date", + "views", + "likes", + "collects", + "comments", + "url" + ], + "type": "ts", + "modulePath": "xiaohongshu/creator-notes.js", + "sourceFile": "xiaohongshu\\creator-notes.ts" + }, + { + "site": "xiaohongshu", + "name": "creator-profile", + "description": "小红书创作者账号信息 (粉丝/关注/获赞/成长等级)", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "xiaohongshu/creator-profile.js", + "sourceFile": "xiaohongshu\\creator-profile.ts" + }, + { + "site": "xiaohongshu", + "name": "creator-stats", + "description": "小红书创作者数据总览 (观看/点赞/收藏/评论/分享/涨粉,含每日趋势)", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "period", + "type": "string", + "default": "seven", + "required": false, + "help": "Stats period: seven or thirty", + "choices": [ + "seven", + "thirty" + ] + } + ], + "columns": [ + "metric", + "total", + "trend" + ], + "type": "ts", + "modulePath": "xiaohongshu/creator-stats.js", + "sourceFile": "xiaohongshu\\creator-stats.ts" + }, + { + "site": "xiaohongshu", + "name": "feed", + "description": "小红书首页推荐 Feed (via Pinia Store Action)", + "domain": "www.xiaohongshu.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of items to return" + } + ], + "columns": [ + "title", + "author", + "likes", + "type", + "url" + ], + "type": "ts", + "modulePath": "xiaohongshu/feed.js", + "sourceFile": "xiaohongshu\\feed.ts" + }, + { + "site": "xiaohongshu", + "name": "notifications", + "description": "小红书通知 (mentions/likes/connections)", + "domain": "www.xiaohongshu.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "type", + "type": "str", + "default": "mentions", + "required": false, + "help": "Notification type: mentions, likes, or connections" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of notifications to return" + } + ], + "columns": [ + "rank", + "user", + "action", + "content", + "note", + "time" + ], + "type": "ts", + "modulePath": "xiaohongshu/notifications.js", + "sourceFile": "xiaohongshu\\notifications.ts" + }, + { + "site": "xiaohongshu", + "name": "publish", + "description": "小红书发布图文笔记 (creator center UI automation)", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "title", + "type": "str", + "required": true, + "help": "笔记标题 (最多20字)" + }, + { + "name": "content", + "type": "str", + "required": true, + "positional": true, + "help": "笔记正文" + }, + { + "name": "images", + "type": "str", + "required": true, + "help": "图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)" + }, + { + "name": "topics", + "type": "str", + "required": false, + "help": "话题标签,逗号分隔,不含 # 号" + }, + { + "name": "draft", + "type": "bool", + "default": false, + "required": false, + "help": "保存为草稿,不直接发布" + } + ], + "columns": [ + "status", + "detail" + ], + "type": "ts", + "modulePath": "xiaohongshu/publish.js", + "sourceFile": "xiaohongshu\\publish.ts" + }, + { + "site": "xiaohongshu", + "name": "search", + "description": "搜索小红书笔记", + "domain": "www.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "author", + "likes", + "published_at", + "url" + ], + "type": "ts", + "modulePath": "xiaohongshu/search.js", + "sourceFile": "xiaohongshu\\search.ts" + }, + { + "site": "xueqiu", + "name": "comments", + "description": "获取单只股票的讨论动态", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Stock symbol, e.g. SH600519, AAPL, or 00700" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of discussion posts to return" + } + ], + "columns": [ + "author", + "text", + "likes", + "replies", + "retweets", + "created_at", + "url" + ], + "type": "ts", + "modulePath": "xueqiu/comments.js", + "sourceFile": "xueqiu\\comments.ts", + "navigateBefore": false + }, + { + "site": "yahoo-finance", + "name": "quote", + "description": "Yahoo Finance 股票行情", + "domain": "finance.yahoo.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "Stock ticker (e.g. AAPL, MSFT, TSLA)" + } + ], + "columns": [ + "symbol", + "name", + "price", + "change", + "changePercent", + "open", + "high", + "low", + "volume", + "marketCap" + ], + "type": "ts", + "modulePath": "yahoo-finance/quote.js", + "sourceFile": "yahoo-finance\\quote.ts" + }, + { + "site": "youtube", + "name": "channel", + "description": "Get YouTube channel info and recent videos", + "domain": "www.youtube.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Channel ID (UCxxxx) or handle (@name)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Max recent videos (max 30)" + } + ], + "columns": [ + "field", + "value" + ], + "type": "ts", + "modulePath": "youtube/channel.js", + "sourceFile": "youtube\\channel.ts" + }, + { + "site": "youtube", + "name": "search", + "description": "Search YouTube videos", + "domain": "www.youtube.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results (max 50)" + }, + { + "name": "type", + "type": "str", + "default": "", + "required": false, + "help": "Filter type: shorts, video, channel, playlist" + }, + { + "name": "upload", + "type": "str", + "default": "", + "required": false, + "help": "Upload date: hour, today, week, month, year" + }, + { + "name": "sort", + "type": "str", + "default": "", + "required": false, + "help": "Sort by: relevance, date, views, rating" + } + ], + "columns": [ + "rank", + "title", + "channel", + "views", + "duration", + "published", + "url" + ], + "type": "ts", + "modulePath": "youtube/search.js", + "sourceFile": "youtube\\search.ts" + }, + { + "site": "zhihu", + "name": "download", + "description": "导出知乎文章为 Markdown 格式", + "domain": "zhuanlan.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "help": "Article URL (zhuanlan.zhihu.com/p/xxx)" + }, + { + "name": "output", + "type": "str", + "default": "./zhihu-articles", + "required": false, + "help": "Output directory" + }, + { + "name": "download-images", + "type": "boolean", + "default": false, + "required": false, + "help": "Download images locally" + } + ], + "columns": [ + "title", + "author", + "publish_time", + "status", + "size" + ], + "type": "ts", + "modulePath": "zhihu/download.js", + "sourceFile": "zhihu\\download.ts" + }, + { + "site": "zhihu", + "name": "hot", + "description": "知乎热榜", + "domain": "www.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of items to return" + } + ], + "columns": [ + "rank", + "title", + "heat", + "answers" + ], + "type": "ts", + "modulePath": "zhihu/hot.js", + "sourceFile": "zhihu\\hot.ts" + }, + { + "site": "zhihu", + "name": "question", + "description": "知乎问题详情和回答", + "domain": "www.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Question ID (numeric)" + }, + { + "name": "limit", + "type": "int", + "default": 5, + "required": false, + "help": "Number of answers" + } + ], + "columns": [ + "rank", + "author", + "votes", + "content" + ], + "type": "ts", + "modulePath": "zhihu/question.js", + "sourceFile": "zhihu\\question.ts" + }, + { + "site": "zhihu", + "name": "search", + "description": "知乎搜索", + "domain": "www.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "type", + "author", + "votes", + "url" + ], + "type": "ts", + "modulePath": "zhihu/search.js", + "sourceFile": "zhihu\\search.ts" + } +] \ No newline at end of file diff --git a/extension/dist/background.js b/extension/dist/background.js index bd1caa221..328a7f799 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,981 +1,1984 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 5e3; - -const attached = /* @__PURE__ */ new Set(); -const networkCaptures = /* @__PURE__ */ new Map(); +//#region src/protocol.ts +/** Default daemon port */ +var DAEMON_PORT = 19825; +var DAEMON_HOST = "localhost"; +var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +/** Base reconnect delay for extension WebSocket (ms) */ +var WS_RECONNECT_BASE_DELAY = 2e3; +/** Max reconnect delay (ms) — kept short since daemon is long-lived */ +var WS_RECONNECT_MAX_DELAY = 5e3; +//#endregion +//#region src/cdp.ts +/** +* CDP execution via chrome.debugger API. +* +* chrome.debugger only needs the "debugger" permission — no host_permissions. +* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// +* tabs (resolveTabId in background.ts filters them). +*/ +var attached = /* @__PURE__ */ new Set(); +var networkCaptures = /* @__PURE__ */ new Map(); +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { - try { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - break; - } - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch { - } - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch { - } + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { + try { + await chrome.debugger.detach({ tabId }); + } catch {} + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch {} + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch {} } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } - } - throw new Error("evaluate: max retries exhausted"); + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { + await ensureAttached(tabId, aggressiveRetry); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); + if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); + const retryMs = isNavigateError ? 200 : 500; + await new Promise((resolve) => setTimeout(resolve, retryMs)); + continue; + } + throw e; + } + throw new Error("evaluate: max retries exhausted"); } -const evaluateAsync = evaluate; +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); + return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; + } finally { + if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); + } } +/** +* Set local file paths on a file input element via CDP DOM.setFileInputFiles. +* This bypasses the need to send large base64 payloads through the message channel — +* Chrome reads the files directly from the local filesystem. +* +* @param tabId - Target tab ID +* @param files - Array of absolute local file paths +* @param selector - CSS selector to find the file input (optional, defaults to first file input) +*/ async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); - } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || "input[type=\"file\"]"; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); } function normalizeCapturePatterns(pattern) { - return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); } function shouldCaptureUrl(url, patterns) { - if (!url) return false; - if (!patterns.length) return true; - return patterns.some((pattern) => url.includes(pattern)); + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); } function normalizeHeaders(headers) { - if (!headers || typeof headers !== "object") return {}; - const out = {}; - for (const [key, value] of Object.entries(headers)) { - out[String(key)] = String(value); - } - return out; + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); + return out; } function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { - const state = networkCaptures.get(tabId); - if (!state) return null; - const existingIndex = state.requestToIndex.get(requestId); - if (existingIndex !== void 0) { - return state.entries[existingIndex] || null; - } - const url = fallback?.url || ""; - if (!shouldCaptureUrl(url, state.patterns)) return null; - const entry = { - kind: "cdp", - url, - method: fallback?.method || "GET", - requestHeaders: fallback?.requestHeaders || {}, - timestamp: Date.now() - }; - state.entries.push(entry); - state.requestToIndex.set(requestId, state.entries.length - 1); - return entry; + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) return state.entries[existingIndex] || null; + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: /* @__PURE__ */ new Map() - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: /* @__PURE__ */ new Map() + }); } async function readNetworkCapture(tabId) { - const state = networkCaptures.get(tabId); - if (!state) return []; - const entries = state.entries.slice(); - state.entries = []; - state.requestToIndex.clear(); - return entries; + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - networkCaptures.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } + if (!attached.has(tabId)) return; + attached.delete(tabId); + networkCaptures.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch {} } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - networkCaptures.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) { - attached.delete(source.tabId); - networkCaptures.delete(source.tabId); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + networkCaptures.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + } + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + if (method === "Network.requestWillBeSent") { + const requestId = String(params?.requestId || ""); + const request = params?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + entry.requestBodyKind = "string"; + entry.requestBodyPreview = postData.postData.slice(0, 4e3); + } + } catch {} + return; + } + if (method === "Network.responseReceived") { + const requestId = String(params?.requestId || ""); + const response = params?.response; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(params?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); + } catch {} + } + }); +} +//#endregion +//#region src/identity.ts +/** +* Page identity mapping — targetId ↔ tabId. +* +* targetId is the cross-layer page identity (CDP target UUID). +* tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension. +* +* Lifecycle: +* - Cache populated lazily via chrome.debugger.getTargets() +* - Evicted on tab close (chrome.tabs.onRemoved) +* - Miss triggers full refresh; refresh miss → hard error (no guessing) +*/ +var targetToTab = /* @__PURE__ */ new Map(); +var tabToTarget = /* @__PURE__ */ new Map(); +/** +* Resolve targetId for a given tabId. +* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). +* Throws if no targetId can be found (page may have been destroyed). +*/ +async function resolveTargetId(tabId) { + const cached = tabToTarget.get(tabId); + if (cached) return cached; + await refreshMappings(); + const result = tabToTarget.get(tabId); + if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); + return result; +} +/** +* Resolve tabId for a given targetId. +* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). +* Throws if no tabId can be found — never falls back to guessing. +*/ +async function resolveTabId$1(targetId) { + const cached = targetToTab.get(targetId); + if (cached !== void 0) return cached; + await refreshMappings(); + const result = targetToTab.get(targetId); + if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); + return result; +} +/** +* Remove mappings for a closed tab. +* Called from chrome.tabs.onRemoved listener. +*/ +function evictTab(tabId) { + const targetId = tabToTarget.get(tabId); + if (targetId) targetToTab.delete(targetId); + tabToTarget.delete(tabId); +} +/** +* Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets(). +*/ +async function refreshMappings() { + const targets = await chrome.debugger.getTargets(); + targetToTab.clear(); + tabToTarget.clear(); + for (const t of targets) if (t.type === "page" && t.tabId !== void 0) { + targetToTab.set(t.id, t.tabId); + tabToTarget.set(t.tabId, t.id); + } +} +//#endregion +//#region ../src/browser/dom-snapshot.ts +/** +* Generate JavaScript code that, when evaluated in a page context via CDP +* Runtime.evaluate, returns a pruned DOM snapshot string optimised for LLMs. +* +* The snapshot output format: +* [42] +* |scroll|
(0.5↑ 3.2↓) +* *[58]Result 1 +* [59]Result 2 +* +* - `[id]` — interactive element with backend index for targeting +* - `*` prefix — newly appeared element (incremental diff) +* - `|scroll|` — scrollable container with page counts +* - `|shadow|` — Shadow DOM boundary +* - `|iframe|` — iframe content +* - `|table|` — markdown table rendering +*/ +function generateSnapshotJs(opts = {}) { + const viewportExpand = opts.viewportExpand ?? 800; + const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200)); + const interactiveOnly = opts.interactiveOnly ?? false; + const maxTextLength = opts.maxTextLength ?? 120; + const includeScrollInfo = opts.includeScrollInfo ?? true; + const bboxDedup = opts.bboxDedup ?? true; + const includeShadowDom = opts.includeShadowDom ?? true; + const includeIframes = opts.includeIframes ?? true; + const maxIframes = opts.maxIframes ?? 5; + const paintOrderCheck = opts.paintOrderCheck ?? true; + const annotateRefs = opts.annotateRefs ?? true; + const reportHidden = opts.reportHidden ?? true; + const filterAds = opts.filterAds ?? true; + const markdownTables = opts.markdownTables ?? true; + const previousHashes = opts.previousHashes ?? null; + return ` +(() => { + 'use strict'; + + // ── Config ───────────────────────────────────────────────────────── + const VIEWPORT_EXPAND = ${viewportExpand}; + const MAX_DEPTH = ${maxDepth}; + const INTERACTIVE_ONLY = ${interactiveOnly}; + const MAX_TEXT_LEN = ${maxTextLength}; + const INCLUDE_SCROLL_INFO = ${includeScrollInfo}; + const BBOX_DEDUP = ${bboxDedup}; + const INCLUDE_SHADOW_DOM = ${includeShadowDom}; + const INCLUDE_IFRAMES = ${includeIframes}; + const MAX_IFRAMES = ${maxIframes}; + const PAINT_ORDER_CHECK = ${paintOrderCheck}; + const ANNOTATE_REFS = ${annotateRefs}; + const REPORT_HIDDEN = ${reportHidden}; + const FILTER_ADS = ${filterAds}; + const MARKDOWN_TABLES = ${markdownTables}; + const PREV_HASHES = ${previousHashes ? `new Set(${previousHashes})` : "null"}; + + // ── Constants ────────────────────────────────────────────────────── + + const SKIP_TAGS = new Set([ + 'script', 'style', 'noscript', 'link', 'meta', 'head', + 'template', 'br', 'wbr', 'col', 'colgroup', + ]); + + const SVG_CHILDREN = new Set([ + 'path', 'rect', 'g', 'circle', 'ellipse', 'line', 'polyline', + 'polygon', 'use', 'defs', 'clippath', 'mask', 'pattern', + 'text', 'tspan', 'lineargradient', 'radialgradient', 'stop', + 'filter', 'fegaussianblur', 'fecolormatrix', 'feblend', + 'symbol', 'marker', 'foreignobject', 'desc', 'title', + ]); + + const INTERACTIVE_TAGS = new Set([ + 'a', 'button', 'input', 'select', 'textarea', 'details', + 'summary', 'option', 'optgroup', + ]); + + const INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'menuitem', 'option', 'radio', 'checkbox', + 'tab', 'textbox', 'combobox', 'slider', 'spinbutton', + 'searchbox', 'switch', 'menuitemcheckbox', 'menuitemradio', + 'treeitem', 'gridcell', 'row', + ]); + + const LANDMARK_ROLES = new Set([ + 'main', 'navigation', 'banner', 'search', 'region', + 'complementary', 'contentinfo', 'form', 'dialog', + ]); + + const LANDMARK_TAGS = new Set([ + 'nav', 'main', 'header', 'footer', 'aside', 'form', + 'search', 'dialog', 'section', 'article', + ]); + + const ATTR_WHITELIST = new Set([ + 'id', 'name', 'type', 'value', 'placeholder', 'title', 'alt', + 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected', + 'aria-disabled', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow', + 'aria-haspopup', 'aria-live', 'aria-required', + 'href', 'src', 'action', 'method', 'for', 'checked', 'selected', + 'disabled', 'required', 'multiple', 'accept', 'min', 'max', + 'pattern', 'maxlength', 'minlength', 'data-testid', 'data-test', + 'contenteditable', 'tabindex', 'autocomplete', + ]); + + const PROPAGATING_TAGS = new Set(['a', 'button']); + + const AD_PATTERNS = [ + 'googleadservices.com', 'doubleclick.net', 'googlesyndication.com', + 'facebook.com/tr', 'analytics.google.com', 'connect.facebook.net', + 'ad.doubleclick', 'pagead', 'adsense', + ]; + + const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i; + + // Search element indicators for heuristic detection + const SEARCH_INDICATORS = new Set([ + 'search', 'magnify', 'glass', 'lookup', 'find', 'query', + 'search-icon', 'search-btn', 'search-button', 'searchbox', + 'fa-search', 'icon-search', 'btn-search', + ]); + + // ── Viewport & Layout Helpers ────────────────────────────────────── + + const vw = window.innerWidth; + const vh = window.innerHeight; + + function isInExpandedViewport(rect) { + if (!rect || (rect.width === 0 && rect.height === 0)) return false; + return rect.bottom > -VIEWPORT_EXPAND && rect.top < vh + VIEWPORT_EXPAND && + rect.right > -VIEWPORT_EXPAND && rect.left < vw + VIEWPORT_EXPAND; + } + + function isVisibleByCSS(el) { + const style = el.style; + if (style.display === 'none') return false; + if (style.visibility === 'hidden' || style.visibility === 'collapse') return false; + if (style.opacity === '0') return false; + try { + const cs = window.getComputedStyle(el); + if (cs.display === 'none') return false; + if (cs.visibility === 'hidden') return false; + if (parseFloat(cs.opacity) <= 0) return false; + if (cs.clip === 'rect(0px, 0px, 0px, 0px)' && cs.position === 'absolute') return false; + if (cs.overflow === 'hidden' && el.offsetWidth === 0 && el.offsetHeight === 0) return false; + } catch {} + return true; + } + + // ── Paint Order Occlusion ────────────────────────────────────────── + + function isOccludedByOverlay(el) { + if (!PAINT_ORDER_CHECK) return false; + try { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + if (cx < 0 || cy < 0 || cx > vw || cy > vh) return false; + const topEl = document.elementFromPoint(cx, cy); + if (!topEl || topEl === el || el.contains(topEl) || topEl.contains(el)) return false; + const cs = window.getComputedStyle(topEl); + if (parseFloat(cs.opacity) < 0.5) return false; + const bg = cs.backgroundColor; + if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false; + return true; + } catch { return false; } + } + + // ── Ad/Noise Detection ───────────────────────────────────────────── + + function isAdElement(el) { + if (!FILTER_ADS) return false; + try { + const id = el.id || ''; + const cls = el.className || ''; + const testStr = id + ' ' + (typeof cls === 'string' ? cls : ''); + if (AD_SELECTOR_RE.test(testStr)) return true; + if (el.tagName === 'IFRAME') { + const src = el.src || ''; + for (const p of AD_PATTERNS) { if (src.includes(p)) return true; } + } + if (el.hasAttribute('data-ad') || el.hasAttribute('data-ad-slot') || + el.hasAttribute('data-adunit') || el.hasAttribute('data-google-query-id')) return true; + } catch {} + return false; + } + + // ── Interactivity Detection ──────────────────────────────────────── + + // Check if element contains a form control within limited depth (handles label/span wrappers) + function hasFormControlDescendant(el, maxDepth = 2) { + if (maxDepth <= 0) return false; + for (const child of el.children || []) { + const tag = child.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'select' || tag === 'textarea') return true; + if (hasFormControlDescendant(child, maxDepth - 1)) return true; + } + return false; + } + + function isInteractive(el) { + const tag = el.tagName.toLowerCase(); + if (INTERACTIVE_TAGS.has(tag)) { + // Skip labels that proxy via "for" to avoid double-activating external inputs + if (tag === 'label') { + if (el.hasAttribute('for')) return false; + // Detect labels that wrap form controls up to two levels deep (label > span > input) + if (hasFormControlDescendant(el, 2)) return true; + } + if (el.disabled && (tag === 'button' || tag === 'input')) return false; + return true; } - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); + // Span wrappers for UI components - check if they contain form controls + if (tag === 'span') { + if (hasFormControlDescendant(el, 2)) return true; } - }); - chrome.debugger.onEvent.addListener(async (source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - const state = networkCaptures.get(tabId); - if (!state) return; - if (method === "Network.requestWillBeSent") { - const requestId = String(params?.requestId || ""); - const request = params?.request; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: request?.url, - method: request?.method, - requestHeaders: normalizeHeaders(request?.headers) - }); - if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); - try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - entry.requestBodyKind = "string"; - entry.requestBodyPreview = postData.postData.slice(0, 4e3); + const role = el.getAttribute('role'); + if (role && INTERACTIVE_ROLES.has(role)) return true; + if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true; + if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true; + // Framework event listener detection (React/Vue/Angular onClick) + if (hasFrameworkListener(el)) return true; + try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {} + if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true; + // Search element heuristic detection + if (isSearchElement(el)) return true; + return false; + } + + function hasFrameworkListener(el) { + try { + // React: __reactProps$xxx / __reactEvents$xxx with onClick/onMouseDown + for (const key of Object.keys(el)) { + if (key.startsWith('__reactProps$') || key.startsWith('__reactEvents$')) { + const props = el[key]; + if (props && (props.onClick || props.onMouseDown || props.onPointerDown)) return true; } - } catch { } - return; + // Vue 3: _vei (Vue Event Invoker) with onClick + if (el._vei && (el._vei.onClick || el._vei.click || el._vei.onMousedown)) return true; + // Vue 2: __vue__ instance with $listeners + if (el.__vue__?.$listeners?.click) return true; + // Angular: ng-reflect-click binding + if (el.hasAttribute('ng-reflect-click')) return true; + } catch { /* ignore errors from cross-origin or frozen objects */ } + return false; + } + + function isSearchElement(el) { + // Check class names for search indicators + // Note: SVG elements have className as SVGAnimatedString (not a string), use baseVal + const className = (typeof el.className === 'string' ? el.className : el.className?.baseVal || '').toLowerCase(); + const classes = className.split(/\\s+/).filter(Boolean); + for (const cls of classes) { + const cleaned = cls.replace(/[^a-z0-9-]/g, ''); + if (SEARCH_INDICATORS.has(cleaned)) return true; } - if (method === "Network.responseReceived") { - const requestId = String(params?.requestId || ""); - const response = params?.response; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: response?.url - }); - if (!entry) return; - entry.responseStatus = response?.status; - entry.responseContentType = response?.mimeType || ""; - entry.responseHeaders = normalizeHeaders(response?.headers); - return; + // Check id for search indicators + const id = el.id?.toLowerCase() || ''; + const cleanedId = id.replace(/[^a-z0-9-]/g, ''); + if (SEARCH_INDICATORS.has(cleanedId)) return true; + // Check data-* attributes for search functionality + for (const attr of el.attributes || []) { + if (attr.name.startsWith('data-')) { + const value = attr.value.toLowerCase(); + for (const kw of SEARCH_INDICATORS) { + if (value.includes(kw)) return true; + } + } } - if (method === "Network.loadingFinished") { - const requestId = String(params?.requestId || ""); - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - try { - const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); - if (typeof body?.body === "string") { - entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); + return false; + } + + function isLandmark(el) { + const role = el.getAttribute('role'); + if (role && LANDMARK_ROLES.has(role)) return true; + return LANDMARK_TAGS.has(el.tagName.toLowerCase()); + } + + // ── Scrollability Detection ──────────────────────────────────────── + + function getScrollInfo(el) { + if (!INCLUDE_SCROLL_INFO) return null; + const sh = el.scrollHeight, ch = el.clientHeight; + const sw = el.scrollWidth, cw = el.clientWidth; + const isV = sh > ch + 5, isH = sw > cw + 5; + if (!isV && !isH) return null; + try { + const cs = window.getComputedStyle(el); + const scrollable = ['auto', 'scroll', 'overlay']; + const tag = el.tagName.toLowerCase(); + const isBody = tag === 'body' || tag === 'html'; + if (isV && !isBody && !scrollable.includes(cs.overflowY)) return null; + const info = {}; + if (isV) { + const above = ch > 0 ? +(el.scrollTop / ch).toFixed(1) : 0; + const below = ch > 0 ? +((sh - ch - el.scrollTop) / ch).toFixed(1) : 0; + if (above > 0 || below > 0) info.v = { above, below }; + } + if (isH && scrollable.includes(cs.overflowX)) { + info.h = { pct: cw > 0 ? Math.round(el.scrollLeft / (sw - cw) * 100) : 0 }; + } + return Object.keys(info).length > 0 ? info : null; + } catch { return null; } + } + + // ── BBox Containment Check ───────────────────────────────────────── + + function isContainedBy(childRect, parentRect, threshold) { + if (!childRect || !parentRect) return false; + const cArea = childRect.width * childRect.height; + if (cArea === 0) return false; + const xO = Math.max(0, Math.min(childRect.right, parentRect.right) - Math.max(childRect.left, parentRect.left)); + const yO = Math.max(0, Math.min(childRect.bottom, parentRect.bottom) - Math.max(childRect.top, parentRect.top)); + return (xO * yO) / cArea >= threshold; + } + + // ── Text Helpers ─────────────────────────────────────────────────── + + function getDirectText(el) { + let text = ''; + for (const child of el.childNodes) { + if (child.nodeType === 3) { + const t = child.textContent.trim(); + if (t) text += (text ? ' ' : '') + t; + } + } + return text; + } + + function capText(s) { + if (!s) return ''; + const t = s.replace(/\\s+/g, ' ').trim(); + return t.length > MAX_TEXT_LEN ? t.slice(0, MAX_TEXT_LEN) + '…' : t; + } + + // ── Element Hashing (for incremental diff) ───────────────────────── + + function hashElement(el) { + // Simple hash: tag + id + className + textContent prefix + const tag = el.tagName || ''; + const id = el.id || ''; + const cls = (typeof el.className === 'string' ? el.className : '').slice(0, 50); + const text = (el.textContent || '').trim().slice(0, 40); + const s = tag + '|' + id + '|' + cls + '|' + text; + let h = 0; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) - h + s.charCodeAt(i)) | 0; + } + return '' + (h >>> 0); // unsigned + } + + // ── Attribute Serialization ──────────────────────────────────────── + + function serializeAttrs(el) { + const parts = []; + for (const attr of el.attributes) { + if (!ATTR_WHITELIST.has(attr.name)) continue; + let val = attr.value.trim(); + if (!val) continue; + if (val.length > 120) val = val.slice(0, 100) + '…'; + if (attr.name === 'type' && val.toLowerCase() === el.tagName.toLowerCase()) continue; + if (attr.name === 'value' && el.getAttribute('type') === 'password') { parts.push('value=••••'); continue; } + if (attr.name === 'href') { + if (val.startsWith('javascript:')) continue; + try { + const u = new URL(val, location.origin); + if (u.origin === location.origin) val = u.pathname + u.search + u.hash; + } catch {} + } + parts.push(attr.name + '=' + val); + } + // Synthetic attributes + const tag = el.tagName; + if (tag === 'INPUT') { + const type = (el.getAttribute('type') || 'text').toLowerCase(); + const fmts = { 'date':'YYYY-MM-DD', 'time':'HH:MM', 'datetime-local':'YYYY-MM-DDTHH:MM', 'month':'YYYY-MM', 'week':'YYYY-W##' }; + if (fmts[type]) parts.push('format=' + fmts[type]); + if (['text','email','tel','url','search','number','date','time','datetime-local','month','week'].includes(type)) { + if (el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value)); + } + if (type === 'password' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=••••'); + if ((type === 'checkbox' || type === 'radio') && el.checked && !parts.some(p => p.startsWith('checked'))) parts.push('checked'); + if (type === 'file' && el.files && el.files.length > 0) parts.push('files=' + Array.from(el.files).map(f => f.name).join(',')); + } + if (tag === 'TEXTAREA' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value)); + if (tag === 'SELECT') { + const sel = el.options?.[el.selectedIndex]; + if (sel && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(sel.textContent)); + const optEls = Array.from(el.options || []).slice(0, 6); + if (optEls.length > 0) { + const ot = optEls.map(o => capText(o.textContent).slice(0, 30)); + if (el.options.length > 6) ot.push('…' + (el.options.length - 6) + ' more'); + parts.push('options=[' + ot.join('|') + ']'); + } + } + return parts.join(' '); + } + + // ── Table → Markdown Serialization ───────────────────────────────── + + function serializeTable(table, depth) { + if (!MARKDOWN_TABLES) return false; + try { + const rows = table.querySelectorAll('tr'); + if (rows.length === 0 || rows.length > 50) return false; // skip huge tables + const grid = []; + let maxCols = 0; + for (const row of rows) { + const cells = []; + for (const cell of row.querySelectorAll('th, td')) { + let text = capText(cell.textContent || ''); + // Include interactive elements in cells + const links = cell.querySelectorAll('a[href]'); + if (links.length === 1 && text) { + const href = links[0].getAttribute('href'); + if (href && !href.startsWith('javascript:')) { + try { + const u = new URL(href, location.origin); + text = '[' + text + '](' + (u.origin === location.origin ? u.pathname + u.search : href) + ')'; + } catch { text = '[' + text + '](' + href + ')'; } + } + } + cells.push(text || ''); } - } catch { + if (cells.length > 0) { + grid.push(cells); + if (cells.length > maxCols) maxCols = cells.length; + } + } + if (grid.length < 2 || maxCols === 0) return false; // need at least header + 1 row + // Pad rows to maxCols + for (const row of grid) { while (row.length < maxCols) row.push(''); } + // Compute column widths + const widths = []; + for (let c = 0; c < maxCols; c++) { + let w = 3; + for (const row of grid) { if (row[c].length > w) w = Math.min(row[c].length, 40); } + widths.push(w); + } + const indent = ' '.repeat(depth); + const tableLines = []; + // Header + tableLines.push(indent + '| ' + grid[0].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |'); + tableLines.push(indent + '| ' + widths.map(w => '-'.repeat(w)).join(' | ') + ' |'); + // Body + for (let r = 1; r < grid.length; r++) { + tableLines.push(indent + '| ' + grid[r].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |'); + } + return tableLines; + } catch { return false; } + } + + // ── Main Tree Walk ───────────────────────────────────────────────── + + let interactiveIndex = 0; + const lines = []; + const hiddenInteractives = []; + const currentHashes = []; + let iframeCount = 0; + + function walk(el, depth, parentPropagatingRect) { + if (depth > MAX_DEPTH) return false; + if (el.nodeType !== 1) return false; + + const tag = el.tagName.toLowerCase(); + if (SKIP_TAGS.has(tag)) return false; + if (isAdElement(el)) return false; + + // SVG: emit tag, collapse children + if (tag === 'svg') { + const attrs = serializeAttrs(el); + const interactive = isInteractive(el); + let prefix = ''; + if (interactive) { + interactiveIndex++; + if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex); + prefix = '[' + interactiveIndex + ']'; } + lines.push(' '.repeat(depth) + prefix + ''); + return interactive; } - }); -} + if (SVG_CHILDREN.has(tag)) return false; -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); -function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { + // Table: try markdown serialization before generic walk + if (tag === 'table' && MARKDOWN_TABLES) { + const tableLines = serializeTable(el, depth); + if (tableLines) { + const indent = ' '.repeat(depth); + lines.push(indent + '|table|'); + for (const tl of tableLines) lines.push(tl); + return false; // tables usually non-interactive + } + // Fall through to generic walk if markdown failed + } + + // iframe handling + if (tag === 'iframe' && INCLUDE_IFRAMES && iframeCount < MAX_IFRAMES) { + return walkIframe(el, depth); + } + + // Visibility check + let rect; + try { rect = el.getBoundingClientRect(); } catch { return false; } + const hasArea = rect.width > 0 && rect.height > 0; + if (hasArea && !isVisibleByCSS(el)) { + if (!(tag === 'input' && el.type === 'file')) return false; + } + + const interactive = isInteractive(el); + + // Viewport threshold pruning + if (hasArea && !isInExpandedViewport(rect)) { + if (interactive && REPORT_HIDDEN) { + const scrollDist = rect.top > vh ? rect.top - vh : -rect.bottom; + const pagesAway = Math.abs(scrollDist / vh).toFixed(1); + const direction = rect.top > vh ? 'below' : 'above'; + const text = capText(getDirectText(el) || el.getAttribute('aria-label') || el.getAttribute('title') || ''); + hiddenInteractives.push({ tag, text, direction, pagesAway }); + } + return false; + } + + // Paint order occlusion + if (interactive && hasArea && isOccludedByOverlay(el)) return false; + + const landmark = isLandmark(el); + const scrollInfo = getScrollInfo(el); + const isScrollable = scrollInfo !== null; + + // BBox dedup + let excludedByParent = false; + if (BBOX_DEDUP && parentPropagatingRect && !interactive) { + if (hasArea && isContainedBy(rect, parentPropagatingRect, 0.95)) { + const hasSemantic = el.hasAttribute('aria-label') || + (el.getAttribute('role') && INTERACTIVE_ROLES.has(el.getAttribute('role'))); + if (!hasSemantic && !['input','select','textarea','label'].includes(tag)) { + excludedByParent = true; + } + } + } + + let propagateRect = parentPropagatingRect; + if (BBOX_DEDUP && PROPAGATING_TAGS.has(tag) && hasArea) propagateRect = rect; + + // Process children + const origLen = lines.length; + let hasInteractiveDescendant = false; + + for (const child of el.children) { + const r = walk(child, depth + 1, propagateRect); + if (r) hasInteractiveDescendant = true; + } + + // Shadow DOM + if (INCLUDE_SHADOW_DOM && el.shadowRoot) { + const shadowOrigLen = lines.length; + for (const child of el.shadowRoot.children) { + const r = walk(child, depth + 1, propagateRect); + if (r) hasInteractiveDescendant = true; + } + if (lines.length > shadowOrigLen) { + lines.splice(shadowOrigLen, 0, ' '.repeat(depth + 1) + '|shadow|'); + } + } + + const childLinesCount = lines.length - origLen; + const text = capText(getDirectText(el)); + + // Decide whether to emit + if (INTERACTIVE_ONLY && !interactive && !landmark && !hasInteractiveDescendant && !text) { + lines.length = origLen; + return false; + } + if (excludedByParent && !interactive && !isScrollable) return hasInteractiveDescendant; + if (!interactive && !isScrollable && !text && childLinesCount === 0 && !landmark) return false; + + // ── Emit node ──────────────────────────────────────────────────── + const indent = ' '.repeat(depth); + let line = indent; + + // Incremental diff: mark new elements with * + if (PREV_HASHES) { + const h = hashElement(el); + currentHashes.push(h); + if (!PREV_HASHES.has(h)) line += '*'; + } else { + currentHashes.push(hashElement(el)); + } + + // Scroll marker + if (isScrollable && !interactive) line += '|scroll|'; + + // Interactive index + data-ref + if (interactive) { + interactiveIndex++; + if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex); + line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']'; + } + + // Tag + attributes + const attrs = serializeAttrs(el); + line += '<' + tag; + if (attrs) line += ' ' + attrs; + + // Scroll info suffix, inline text, or self-close + if (isScrollable && scrollInfo) { + const parts = []; + if (scrollInfo.v) parts.push(scrollInfo.v.above + '↑ ' + scrollInfo.v.below + '↓'); + if (scrollInfo.h) parts.push('h:' + scrollInfo.h.pct + '%'); + line += ' /> (' + parts.join(', ') + ')'; + } else if (text && childLinesCount === 0) { + line += '>' + text + ''; + } else { + line += ' />'; + } + + lines.splice(origLen, 0, line); + if (text && childLinesCount > 0) lines.splice(origLen + 1, 0, indent + ' ' + text); + + return interactive || hasInteractiveDescendant; + } + + // ── iframe Processing ────────────────────────────────────────────── + + function walkIframe(el, depth) { + const indent = ' '.repeat(depth); + try { + const doc = el.contentDocument; + if (!doc || !doc.body) { + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe| (cross-origin)'); + return false; + } + iframeCount++; + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe|'); + let has = false; + for (const child of doc.body.children) { + if (walk(child, depth + 1, null)) has = true; + } + return has; + } catch { + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe| (blocked)'); + return false; + } } + + // ── Entry Point ──────────────────────────────────────────────────── + + lines.push('url: ' + location.href); + lines.push('title: ' + document.title); + lines.push('viewport: ' + vw + 'x' + vh); + const pageScrollInfo = getScrollInfo(document.documentElement) || getScrollInfo(document.body); + if (pageScrollInfo && pageScrollInfo.v) { + lines.push('page_scroll: ' + pageScrollInfo.v.above + '↑ ' + pageScrollInfo.v.below + '↓'); + } + lines.push('---'); + + const root = document.body || document.documentElement; + if (root) walk(root, 0, null); + + // Hidden interactive elements hint + if (REPORT_HIDDEN && hiddenInteractives.length > 0) { + lines.push('---'); + lines.push('hidden_interactive (' + hiddenInteractives.length + '):'); + const shown = hiddenInteractives.slice(0, 10); + for (const h of shown) { + const label = h.text ? ' "' + h.text + '"' : ''; + lines.push(' <' + h.tag + '>' + label + ' ~' + h.pagesAway + ' pages ' + h.direction); + } + if (hiddenInteractives.length > 10) lines.push(' …' + (hiddenInteractives.length - 10) + ' more'); + } + + // Footer + lines.push('---'); + lines.push('interactive: ' + interactiveIndex + ' | iframes: ' + iframeCount); + + // Store hashes on window for next diff snapshot + try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {} + + return lines.join('\\n'); +})() + `.trim(); +} +//#endregion +//#region src/background.ts +var ws = null; +var reconnectTimer = null; +var reconnectAttempts = 0; +var _origLog = console.log.bind(console); +var _origWarn = console.warn.bind(console); +var _origError = console.error.bind(console); +function forwardLog(level, args) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ + type: "log", + level, + msg, + ts: Date.now() + })); + } catch {} } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; +/** +* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket +* connection. fetch() failures are silently catchable; new WebSocket() is not +* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any +* JS handler can intercept it. By keeping the probe inside connect() every +* call site remains unchanged and the guard can never be accidentally skipped. +*/ async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; + } catch { + return; + } + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ + type: "hello", + version: chrome.runtime.getManifest().version + })); + }; + ws.onmessage = async (event) => { + try { + const result = await handleCommand(JSON.parse(event.data)); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +/** +* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. +* The keepalive alarm (~24s) will still call connect() periodically, but at a +* much lower frequency — reducing console noise when the daemon is not running. +*/ +var MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +var automationSessions = /* @__PURE__ */ new Map(); +var WINDOW_IDLE_TIMEOUT = 3e4; +var windowFocused = false; function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; + return workspace?.trim() || "default"; } function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - if (!current.owned) { - console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); - automationSessions.delete(workspace); - return; - } - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - } - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch {} + automationSessions.delete(workspace); + }, WINDOW_IDLE_TIMEOUT); } +/** Get or create the dedicated automation window. +* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. +* This avoids an extra blank-page→target-domain navigation on first command. +*/ async function getAutomationWindow(workspace, initialUrl) { - const existing = automationSessions.get(workspace); - if (existing) { - try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: false, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, - owned: true, - preferredTabId: null - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); - resetWindowIdleTimer(workspace); - const tabs = await chrome.tabs.query({ windowId: win.id }); - if (tabs[0]?.id) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === tabs[0].id && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else { - chrome.tabs.onUpdated.addListener(listener); - } - }); - } - return session.windowId; -} -chrome.windows.onRemoved.addListener((windowId) => { - for (const [workspace, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - } + const existing = automationSessions.get(workspace); + if (existing) try { + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + automationSessions.delete(workspace); + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: windowFocused, + width: 1280, + height: 900, + type: "normal" + }); + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); + resetWindowIdleTimer(workspace); + const tabs = await chrome.tabs.query({ windowId: win.id }); + if (tabs[0]?.id) await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === tabs[0].id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else chrome.tabs.onUpdated.addListener(listener); + }); + return session.windowId; +} +chrome.windows.onRemoved.addListener(async (windowId) => { + for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } +}); +chrome.tabs.onRemoved.addListener((tabId) => { + evictTab(tabId); }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .4 }); + registerListeners(); + connect(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - } - return false; + if (msg?.type === "getStatus") sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + else if (msg?.type === "getPageState") { + handleGetPageState().then((result) => { + sendResponse(result); + }).catch((err) => { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err) + }); + }); + return true; + } + return false; +}); +chrome.commands.onCommand.addListener((command) => { + if (command === "get-page-state") handleGetPageState().then((result) => { + if (result.ok) chrome.notifications.create("opencli-page-state", { + type: "basic", + title: "OpenCLI", + message: "Page state captured successfully", + iconUrl: "icons/icon-48.png" + }); + else chrome.notifications.create("opencli-page-state-error", { + type: "basic", + title: "OpenCLI Error", + message: `Failed to capture page state: ${result.error}`, + iconUrl: "icons/icon-48.png" + }); + }); }); +async function handleGetPageState() { + const workspace = "browser:default"; + try { + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => tab.id && (tab.url?.startsWith("http://") || tab.url?.startsWith("https://"))) ?? fallbackTabs.find((tab) => tab.id && (tab.url?.startsWith("http://") || tab.url?.startsWith("https://"))) ?? allTabs.find((tab) => tab.id && (tab.url?.startsWith("http://") || tab.url?.startsWith("https://"))); + if (!boundTab?.id) return { + id: "popup-state", + ok: false, + error: "No active debuggable tab found" + }; + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + const snapshotJs = generateSnapshotJs({ + viewportExpand: 2e3, + maxDepth: 50, + interactiveOnly: false, + maxTextLength: 120, + includeScrollInfo: true, + bboxDedup: true, + includeShadowDom: true, + includeIframes: true, + maxIframes: 5, + paintOrderCheck: true, + annotateRefs: true, + reportHidden: true, + filterAds: true, + markdownTables: true, + previousHashes: null + }); + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + const data = await evaluateAsync(boundTab.id, snapshotJs, aggressive); + const markElementsScript = ` + (() => { + 'use strict'; + + // 移除之前的标记 + document.querySelectorAll('.opencli-element-mark').forEach(el => el.remove()); + + // 遍历所有带有 data-opencli-ref 属性的元素 + document.querySelectorAll('[data-opencli-ref]').forEach(el => { + try { + const rect = el.getBoundingClientRect(); + const ref = el.getAttribute('data-opencli-ref'); + + if (rect.width > 0 && rect.height > 0 && ref) { + // 创建标记元素 + const mark = document.createElement('div'); + mark.className = 'opencli-element-mark'; + mark.textContent = ref; + mark.style.position = 'absolute'; + mark.style.left = '0'; + mark.style.top = '0'; + mark.style.transform = 'translate(-50%, -50%)'; + mark.style.background = 'rgba(255, 0, 0, 0.8)'; + mark.style.color = 'white'; + mark.style.fontSize = '12px'; + mark.style.fontWeight = 'bold'; + mark.style.padding = '2px 6px'; + mark.style.borderRadius = '10px'; + mark.style.zIndex = '9999'; + mark.style.pointerEvents = 'none'; + mark.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.3)'; + + // 计算中心位置 + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // 设置位置 + mark.style.left = centerX + 'px'; + mark.style.top = centerY + 'px'; + + // 添加到文档 + document.body.appendChild(mark); + } + } catch (e) { + // 忽略错误 + } + }); + })() + `; + try { + await evaluateAsync(boundTab.id, markElementsScript, aggressive); + } catch (err) {} + return { + id: "popup-state", + ok: true, + data + }; + } catch (err) { + return { + id: "popup-state", + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, workspace); - case "navigate": - return await handleNavigate(cmd, workspace); - case "tabs": - return await handleTabs(cmd, workspace); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, workspace); - case "close-window": - return await handleCloseWindow(cmd, workspace); - case "cdp": - return await handleCdp(cmd, workspace); - case "sessions": - return await handleSessions(cmd); - case "set-file-input": - return await handleSetFileInput(cmd, workspace); - case "insert-text": - return await handleInsertText(cmd, workspace); - case "bind-current": - return await handleBindCurrent(cmd, workspace); - case "network-capture-start": - return await handleNetworkCaptureStart(cmd, workspace); - case "network-capture-read": - return await handleNetworkCaptureRead(cmd, workspace); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const workspace = getWorkspaceKey(cmd.workspace); + windowFocused = cmd.windowFocused === true; + resetWindowIdleTimer(workspace); + try { + switch (cmd.action) { + case "exec": return await handleExec(cmd, workspace); + case "navigate": return await handleNavigate(cmd, workspace); + case "tabs": return await handleTabs(cmd, workspace); + case "cookies": return await handleCookies(cmd); + case "screenshot": return await handleScreenshot(cmd, workspace); + case "close-window": return await handleCloseWindow(cmd, workspace); + case "cdp": return await handleCdp(cmd, workspace); + case "sessions": return await handleSessions(cmd); + case "set-file-input": return await handleSetFileInput(cmd, workspace); + case "insert-text": return await handleInsertText(cmd, workspace); + case "bind-current": return await handleBindCurrent(cmd, workspace); + case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace); + case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace); + default: return { + id: cmd.id, + ok: false, + error: `Unknown action: ${cmd.action}` + }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } -const BLANK_PAGE = "about:blank"; +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE = "about:blank"; +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } +/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } +/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } function matchesDomain(url, domain) { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); - } catch { - return false; - } + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } } function matchesBindCriteria(tab, cmd) { - if (!tab.id || !isDebuggableUrl(tab.url)) return false; - if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; - if (cmd.matchPathPrefix) { - try { - const parsed = new URL(tab.url); - if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; - } catch { - return false; - } - } - return true; + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) try { + if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + return true; } function setWorkspaceSession(workspace, session) { - const existing = automationSessions.get(workspace); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.set(workspace, { - ...session, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }); + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }); } +/** +* Resolve tabId from command's page (targetId) or legacy tabId field. +* page (targetId) takes precedence. Returns undefined if neither is provided. +*/ +async function resolveCommandTabId(cmd) { + if (cmd.page) return resolveTabId$1(cmd.page); + return cmd.tabId; +} +/** +* Resolve target tab in the automation window, returning both the tabId and +* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). +*/ async function resolveTab(tabId, workspace, initialUrl) { - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; - if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { - return { tabId, tab: moved }; - } - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const existingSession = automationSessions.get(workspace); - if (existingSession?.preferredTabId !== null) { - try { - const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); - if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; - } catch { - automationSessions.delete(workspace); - } - } - const windowId = await getAutomationWindow(workspace, initialUrl); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return { tabId: newTab.id, tab: newTab }; + if (tabId !== void 0) try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { + tabId, + tab + }; + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { + tabId, + tab: moved + }; + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { + tabId: preferredTab.id, + tab: preferredTab + }; + } catch { + automationSessions.delete(workspace); + } + const windowId = await getAutomationWindow(workspace, initialUrl); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return { + tabId: debuggableTab.id, + tab: debuggableTab + }; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { + tabId: reuseTab.id, + tab: updated + }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch {} + } + const newTab = await chrome.tabs.create({ + windowId, + url: BLANK_PAGE, + active: true + }); + if (!newTab.id) throw new Error("Failed to create tab in automation window"); + return { + tabId: newTab.id, + tab: newTab + }; +} +/** Build a page-scoped success result with targetId resolved from tabId */ +async function pageScopedResult(id, tabId, data) { + return { + id, + ok: true, + data, + page: await resolveTargetId(tabId) + }; } +/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, workspace, initialUrl) { - const resolved = await resolveTab(tabId, workspace, initialUrl); - return resolved.tabId; + return (await resolveTab(tabId, workspace, initialUrl)).tabId; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - if (session.preferredTabId !== null) { - try { - return [await chrome.tabs.get(session.preferredTabId)]; - } catch { - automationSessions.delete(workspace); - return []; - } - } - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + if (session.preferredTabId !== null) try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); + return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - const data = await evaluateAsync(tabId, cmd.code, aggressive); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.code) return { + id: cmd.id, + ok: false, + error: "Missing code" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + const data = await evaluateAsync(tabId, cmd.code, aggressive); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return { - id: cmd.id, - ok: true, - data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } - }; - } - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return { - id: cmd.id, - ok: true, - data: { title: tab.title, url: tab.url, tabId, timedOut } - }; + if (!cmd.url) return { + id: cmd.id, + ok: false, + error: "Missing url" + }; + if (!isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const resolved = await resolveTab(await resolveCommandTabId(cmd), workspace, cmd.url); + const tabId = resolved.tabId; + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, { + title: beforeTab.title, + url: beforeTab.url, + timedOut: false + }); + await detach(tabId); + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); + } catch {} + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + if (session && tab.windowId !== session.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + return pageScopedResult(cmd.id, tabId, { + title: tab.title, + url: tab.url, + timedOut + }); } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = tabs.map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { id: cmd.id, ok: true, data: { closed: target.id } }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { id: cmd.id, ok: true, data: { closed: tabId } }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) - return { id: cmd.id, ok: false, error: "Missing index or tabId" }; - if (cmd.tabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmd.tabId); - } catch { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; - } - if (!session || tab.windowId !== session.windowId) { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; - } - await chrome.tabs.update(cmd.tabId, { active: true }); - return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; - } - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return { id: cmd.id, ok: true, data: { selected: target.id } }; - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } + switch (cmd.op) { + case "list": { + const tabs = await listAutomationWebTabs(workspace); + const data = await Promise.all(tabs.map(async (t, i) => { + let page; + try { + page = t.id ? await resolveTargetId(t.id) : void 0; + } catch {} + return { + index: i, + page, + url: t.url, + title: t.title, + active: t.active + }; + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const windowId = await getAutomationWindow(workspace); + const tab = await chrome.tabs.create({ + windowId, + url: cmd.url ?? BLANK_PAGE, + active: true + }); + if (!tab.id) return { + id: cmd.id, + ok: false, + error: "Failed to create tab" + }; + return pageScopedResult(cmd.id, tab.id, { url: tab.url }); + } + case "close": { + if (cmd.index !== void 0) { + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + const closedPage = await resolveTargetId(target.id).catch(() => void 0); + await chrome.tabs.remove(target.id); + await detach(target.id); + return { + id: cmd.id, + ok: true, + data: { closed: closedPage } + }; + } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + const closedPage = await resolveTargetId(tabId).catch(() => void 0); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { + id: cmd.id, + ok: true, + data: { closed: closedPage } + }; + } + case "select": { + if (cmd.index === void 0 && cmd.page === void 0 && cmd.tabId === void 0) return { + id: cmd.id, + ok: false, + error: "Missing index or page" + }; + const cmdTabId = await resolveCommandTabId(cmd); + if (cmdTabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmdTabId); + } catch { + return { + id: cmd.id, + ok: false, + error: `Page no longer exists` + }; + } + if (!session || tab.windowId !== session.windowId) return { + id: cmd.id, + ok: false, + error: `Page is not in the automation window` + }; + await chrome.tabs.update(cmdTabId, { active: true }); + return pageScopedResult(cmd.id, cmdTabId, { selected: true }); + } + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.update(target.id, { active: true }); + return pageScopedResult(cmd.id, target.id, { selected: true }); + } + default: return { + id: cmd.id, + ok: false, + error: `Unknown tabs op: ${cmd.op}` + }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; + if (!cmd.domain && !cmd.url) return { + id: cmd.id, + ok: false, + error: "Cookie scope required: provide domain or url to avoid dumping all cookies" + }; + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const data = (await chrome.cookies.getAll(details)).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { + id: cmd.id, + ok: true, + data + }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } -const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ - // Agent DOM context - "Accessibility.getFullAXTree", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - // Native input events - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - // Page metrics & screenshots - "Page.getLayoutMetrics", - "Page.captureScreenshot", - // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) - "Runtime.enable", - // Emulation (used by screenshot full-page) - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" +/** CDP methods permitted via the 'cdp' passthrough action. */ +var CDP_ALLOWLIST = new Set([ + "Accessibility.getFullAXTree", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Runtime.enable", + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, workspace) { - if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { - return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - await ensureAttached(tabId, aggressive); - const data = await chrome.debugger.sendCommand( - { tabId }, - cmd.cdpMethod, - cmd.cdpParams ?? {} - ); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.cdpMethod) return { + id: cmd.id, + ok: false, + error: "Missing cdpMethod" + }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { + id: cmd.id, + ok: false, + error: `CDP method not permitted: ${cmd.cdpMethod}` + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + await ensureAttached(tabId, workspace.startsWith("browser:") || workspace.startsWith("operate:")); + const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {}); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - if (session.owned) { - try { - await chrome.windows.remove(session.windowId); - } catch { - } - } - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { id: cmd.id, ok: true, data: { closed: true } }; + const session = automationSessions.get(workspace); + if (session) { + if (session.owned) try { + await chrome.windows.remove(session.windowId); + } catch {} + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } + return { + id: cmd.id, + ok: true, + data: { closed: true } + }; } async function handleSetFileInput(cmd, workspace) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { - return { id: cmd.id, ok: false, error: "Missing or empty files array" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return { id: cmd.id, ok: true, data: { count: cmd.files.length } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { + id: cmd.id, + ok: false, + error: "Missing or empty files array" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleInsertText(cmd, workspace) { - if (typeof cmd.text !== "string") { - return { id: cmd.id, ok: false, error: "Missing text payload" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await insertText(tabId, cmd.text); - return { id: cmd.id, ok: true, data: { inserted: true } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (typeof cmd.text !== "string") return { + id: cmd.id, + ok: false, + error: "Missing text payload" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + await insertText(tabId, cmd.text); + return pageScopedResult(cmd.id, tabId, { inserted: true }); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNetworkCaptureStart(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await startNetworkCapture(tabId, cmd.pattern); - return { id: cmd.id, ok: true, data: { started: true } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + await startNetworkCapture(tabId, cmd.pattern); + return pageScopedResult(cmd.id, tabId, { started: true }); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNetworkCaptureRead(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await readNetworkCapture(tabId); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); + try { + const data = await readNetworkCapture(tabId); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { id: cmd.id, ok: true, data }; + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { + id: cmd.id, + ok: true, + data + }; } async function handleBindCurrent(cmd, workspace) { - const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const allTabs = await chrome.tabs.query({}); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); - if (!boundTab?.id) { - return { - id: cmd.id, - ok: false, - error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" - }; - } - setWorkspaceSession(workspace, { - windowId: boundTab.windowId, - owned: false, - preferredTabId: boundTab.id - }); - resetWindowIdleTimer(workspace); - console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); - return { - id: cmd.id, - ok: true, - data: { - tabId: boundTab.id, - windowId: boundTab.windowId, - url: boundTab.url, - title: boundTab.title, - workspace - } - }; + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" + }; + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return pageScopedResult(cmd.id, boundTab.id, { + url: boundTab.url, + title: boundTab.title, + workspace + }); } +//#endregion diff --git a/extension/manifest.json b/extension/manifest.json index 41599886c..fe01105bf 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -34,5 +34,13 @@ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, - "homepage_url": "https://github.com/jackwener/opencli" + "homepage_url": "https://github.com/jackwener/opencli", + "commands": { + "get-page-state": { + "suggested_key": { + "default": "Alt+S" + }, + "description": "Get page state" + } + } } diff --git a/extension/popup.html b/extension/popup.html index 02ca1b972..5aa1704cd 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -54,6 +54,36 @@ border-radius: 3px; font-size: 11px; } + .action-button { + margin-top: 14px; + width: 100%; + padding: 10px; + background: #007aff; + color: white; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .action-button:hover { + background: #0066cc; + } + .action-button:disabled { + background: #cccccc; + cursor: not-allowed; + } + .result { + margin-top: 14px; + padding: 10px; + border-radius: 8px; + background: #f5f5f5; + font-size: 11px; + color: #333; + max-height: 200px; + overflow-y: auto; + display: none; + } .footer { margin-top: 14px; text-align: center; @@ -76,6 +106,8 @@

OpenCLI

This is normal. The extension connects automatically when you run any opencli command.
+ +
diff --git a/extension/popup.js b/extension/popup.js index 4bd3a7d48..8f2906da5 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,23 +3,72 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { const dot = document.getElementById('dot'); const status = document.getElementById('status'); const hint = document.getElementById('hint'); + const stateButton = document.getElementById('stateButton'); + if (chrome.runtime.lastError || !resp) { dot.className = 'dot disconnected'; status.innerHTML = 'No daemon connected'; hint.style.display = 'block'; + stateButton.disabled = true; return; } if (resp.connected) { dot.className = 'dot connected'; status.innerHTML = 'Connected to daemon'; hint.style.display = 'none'; + stateButton.disabled = false; } else if (resp.reconnecting) { dot.className = 'dot connecting'; status.innerHTML = 'Reconnecting...'; hint.style.display = 'none'; + stateButton.disabled = true; } else { dot.className = 'dot disconnected'; status.innerHTML = 'No daemon connected'; hint.style.display = 'block'; + stateButton.disabled = true; + } +}); + +// Add event listener for state button +document.getElementById('stateButton').addEventListener('click', async () => { + const resultDiv = document.getElementById('result'); + const stateButton = document.getElementById('stateButton'); + + // Show loading state + stateButton.disabled = true; + stateButton.textContent = 'Loading...'; + resultDiv.style.display = 'block'; + resultDiv.textContent = 'Getting page state...'; + + try { + // Send message to background script to get page state + chrome.runtime.sendMessage({ type: 'getPageState' }, (response) => { + if (chrome.runtime.lastError) { + resultDiv.textContent = `Error: ${chrome.runtime.lastError.message}`; + stateButton.disabled = false; + stateButton.textContent = 'Get Page State'; + return; + } + + if (response && response.ok) { + // Format the result + if (typeof response.data === 'string') { + resultDiv.textContent = response.data; + } else { + resultDiv.textContent = JSON.stringify(response.data, null, 2); + } + } else { + resultDiv.textContent = `Error: ${response?.error || 'Failed to get page state'}`; + } + + // Reset button state + stateButton.disabled = false; + stateButton.textContent = 'Get Page State'; + }); + } catch (error) { + resultDiv.textContent = `Error: ${error.message}`; + stateButton.disabled = false; + stateButton.textContent = 'Get Page State'; } }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 7e1852af5..a1cb9c5a1 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -9,6 +9,7 @@ import type { Command, Result } from './protocol'; import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; import * as identity from './identity'; +import { generateSnapshotJs } from '@src/browser/dom-snapshot.js'; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; @@ -265,10 +266,171 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { connected: ws?.readyState === WebSocket.OPEN, reconnecting: reconnectTimer !== null, }); + } else if (msg?.type === 'getPageState') { + // Handle getPageState message + handleGetPageState().then(result => { + sendResponse(result); + }).catch(err => { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + }); + return true; // Indicates we will send a response asynchronously } return false; }); +// ─── Keyboard Shortcut Listener ──────────────────────────────────── + +// Listen for keyboard shortcuts +chrome.commands.onCommand.addListener((command) => { + if (command === 'get-page-state') { + handleGetPageState().then(result => { + // Show notification if needed + if (result.ok) { + chrome.notifications.create('opencli-page-state', { + type: 'basic', + title: 'OpenCLI', + message: 'Page state captured successfully', + iconUrl: 'icons/icon-48.png' + }); + } else { + chrome.notifications.create('opencli-page-state-error', { + type: 'basic', + title: 'OpenCLI Error', + message: `Failed to capture page state: ${result.error}`, + iconUrl: 'icons/icon-48.png' + }); + } + }); + } +}); + +// ─── Get Page State Handler ───────────────────────────────────────── + +async function handleGetPageState(): Promise { + const workspace = 'browser:default'; + try { + // 绑定到当前活跃标签页 + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => tab.id && (tab.url?.startsWith('http://') || tab.url?.startsWith('https://'))) + ?? fallbackTabs.find((tab) => tab.id && (tab.url?.startsWith('http://') || tab.url?.startsWith('https://'))) + ?? allTabs.find((tab) => tab.id && (tab.url?.startsWith('http://') || tab.url?.startsWith('https://'))); + + if (!boundTab?.id) { + return { + id: 'popup-state', + ok: false, + error: 'No active debuggable tab found', + }; + } + + // 设置工作区会话 + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id, + }); + resetWindowIdleTimer(workspace); + + // 生成快照脚本 + const snapshotJs = generateSnapshotJs({ + viewportExpand: 2000, + maxDepth: 50, + interactiveOnly: false, + maxTextLength: 120, + includeScrollInfo: true, + bboxDedup: true, + includeShadowDom: true, + includeIframes: true, + maxIframes: 5, + paintOrderCheck: true, + annotateRefs: true, + reportHidden: true, + filterAds: true, + markdownTables: true, + previousHashes: null, + }); + + // 执行快照脚本 + const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:'); + const data = await executor.evaluateAsync(boundTab.id, snapshotJs, aggressive); + + // 在页面上标记元素序号 + const markElementsScript = ` + (() => { + 'use strict'; + + // 移除之前的标记 + document.querySelectorAll('.opencli-element-mark').forEach(el => el.remove()); + + // 遍历所有带有 data-opencli-ref 属性的元素 + document.querySelectorAll('[data-opencli-ref]').forEach(el => { + try { + const rect = el.getBoundingClientRect(); + const ref = el.getAttribute('data-opencli-ref'); + + if (rect.width > 0 && rect.height > 0 && ref) { + // 创建标记元素 + const mark = document.createElement('div'); + mark.className = 'opencli-element-mark'; + mark.textContent = ref; + mark.style.position = 'absolute'; + mark.style.left = '0'; + mark.style.top = '0'; + mark.style.transform = 'translate(-50%, -50%)'; + mark.style.background = 'rgba(255, 0, 0, 0.8)'; + mark.style.color = 'white'; + mark.style.fontSize = '12px'; + mark.style.fontWeight = 'bold'; + mark.style.padding = '2px 6px'; + mark.style.borderRadius = '10px'; + mark.style.zIndex = '9999'; + mark.style.pointerEvents = 'none'; + mark.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.3)'; + + // 计算中心位置 + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // 设置位置 + mark.style.left = centerX + 'px'; + mark.style.top = centerY + 'px'; + + // 添加到文档 + document.body.appendChild(mark); + } + } catch (e) { + // 忽略错误 + } + }); + })() + `; + + // 执行标记脚本 + try { + await executor.evaluateAsync(boundTab.id, markElementsScript, aggressive); + } catch (err) { + // 忽略标记错误,不影响主功能 + } + + return { + id: 'popup-state', + ok: true, + data, + }; + } catch (err) { + return { + id: 'popup-state', + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + // ─── Command dispatcher ───────────────────────────────────────────── async function handleCommand(cmd: Command): Promise { diff --git a/extension/vite.config.ts b/extension/vite.config.ts index f7cd0ecc1..618683865 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -15,4 +15,9 @@ export default defineConfig({ target: 'esnext', minify: false, }, + resolve: { + alias: { + '@src': resolve(__dirname, '../src'), + }, + }, }); diff --git a/package-lock.json b/package-lock.json index f0db4a55d..5c62e09ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -196,6 +196,7 @@ "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", @@ -404,31 +405,6 @@ } } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -436,7 +412,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2168,6 +2143,7 @@ "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", @@ -2496,6 +2472,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -2624,6 +2601,7 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3095,6 +3073,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3483,6 +3462,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3512,6 +3492,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3645,6 +3626,7 @@ "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4209,6 +4191,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4351,6 +4334,7 @@ "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30",