From fcf32a5aa1daf5252afd87e6d9dde49507cba5b9 Mon Sep 17 00:00:00 2001 From: yan5xu Date: Thu, 28 May 2026 20:16:22 +0800 Subject: [PATCH] feat(dx): goto, cookies, source grep, trace --exclude-static, network --exclude-static New commands: - goto --tab: navigate existing tab via Page.navigate (keeps SPA context) - cookies --tab [--filter]: view page cookies via Network.getCookies - source grep --tab: search loaded JS sources for API endpoints Improvements: - trace events --exclude-static: filter out .js/.css/.png/.woff and _next/static - network requests --exclude-static: same filtering for network command - trace --exclude-static filters both requests AND their matching responses - trace body returns requestBody alongside responseBody for POST requests - open command output shows short tab ID prominently --- packages/cli/src/commands/cookies.ts | 57 ++++++++ packages/cli/src/commands/goto.ts | 46 +++++++ packages/cli/src/commands/network.ts | 2 + packages/cli/src/commands/open.ts | 13 +- packages/cli/src/commands/source.ts | 58 +++++++++ packages/cli/src/commands/trace.ts | 20 ++- packages/cli/src/index.ts | 49 ++++++- packages/daemon/src/command-dispatch.ts | 165 +++++++++++++++++++++++- packages/shared/src/commands.ts | 30 +++++ packages/shared/src/protocol.ts | 17 ++- 10 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/cookies.ts create mode 100644 packages/cli/src/commands/goto.ts create mode 100644 packages/cli/src/commands/source.ts diff --git a/packages/cli/src/commands/cookies.ts b/packages/cli/src/commands/cookies.ts new file mode 100644 index 0000000..3eea4e6 --- /dev/null +++ b/packages/cli/src/commands/cookies.ts @@ -0,0 +1,57 @@ +/** + * cookies 命令 - 查看当前页面的 cookies + * + * 用法: + * bb-browser cookies --tab + * bb-browser cookies --tab --filter + */ + +import type { Request, Response } from "@bb-browser/shared"; +import { sendCommand } from "../client.js"; + +export interface CookiesOptions { + json?: boolean; + tabId?: string | number; + filter?: string; +} + +export async function cookiesCommand( + options: CookiesOptions = {} +): Promise { + const request: Request = { + method: "cookies", + tabId: options.tabId, + filter: options.filter, + }; + + const response: Response = await sendCommand(request); + + if (options.json) { + console.log(JSON.stringify(response)); + return; + } + + if (response.error) { + throw new Error(response.error.message || "Cookies command failed"); + } + + const data = response.result; + const cookies = (data as any)?.cookies || []; + + if (cookies.length === 0) { + console.log("No cookies found"); + return; + } + + console.log(`Cookies (${cookies.length}):\n`); + for (const c of cookies) { + const flags: string[] = []; + if (c.httpOnly) flags.push("httpOnly"); + if (c.secure) flags.push("secure"); + const expiresStr = c.expires > 0 + ? `expires=${new Date(c.expires * 1000).toISOString().split("T")[0]}` + : "session"; + const flagStr = flags.length > 0 ? ` ${flags.join(" ")}` : ""; + console.log(`name=${c.name} domain=${c.domain} path=${c.path}${flagStr} ${expiresStr}`); + } +} diff --git a/packages/cli/src/commands/goto.ts b/packages/cli/src/commands/goto.ts new file mode 100644 index 0000000..fb628f6 --- /dev/null +++ b/packages/cli/src/commands/goto.ts @@ -0,0 +1,46 @@ +/** + * goto 命令 - 在当前 tab 中导航到新 URL(保持 tab 上下文) + * + * 用法: + * bb-browser goto --tab + */ + +import type { Request, Response } from "@bb-browser/shared"; +import { sendCommand } from "../client.js"; +import { ensureDaemonRunning } from "../daemon-manager.js"; + +export interface GotoOptions { + json?: boolean; + tabId?: string | number; +} + +export async function gotoCommand( + url: string, + options: GotoOptions = {} +): Promise { + if (!url) { + throw new Error("Missing URL parameter"); + } + + await ensureDaemonRunning(); + + const request: Request = { + method: "goto", + url, + tabId: options.tabId, + }; + + const response: Response = await sendCommand(request); + + if (options.json) { + console.log(JSON.stringify(response, null, 2)); + } else { + if (response.result) { + console.log(`tab: ${response.result.tab}`); + console.log(`url: ${response.result.url}`); + } else { + console.error(`Error: ${response.error?.message}`); + process.exit(1); + } + } +} diff --git a/packages/cli/src/commands/network.ts b/packages/cli/src/commands/network.ts index 7aabe35..ee7aa28 100644 --- a/packages/cli/src/commands/network.ts +++ b/packages/cli/src/commands/network.ts @@ -14,6 +14,7 @@ interface NetworkOptions { since?: string; method?: string; status?: string; + excludeStatic?: boolean; } export async function networkCommand( @@ -37,6 +38,7 @@ export async function networkCommand( body: options.body, } : undefined, withBody: subCommand === "requests" ? options.withBody : undefined, + excludeStatic: subCommand === "requests" ? options.excludeStatic : undefined, since, httpMethod: subCommand === "requests" ? options.method : undefined, status: subCommand === "requests" ? options.status : undefined, diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 5e36013..1eb993b 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -65,17 +65,18 @@ export async function openCommand( console.log(JSON.stringify(response, null, 2)); } else { if (response.result) { - console.log(`已打开: ${response.result?.url ?? normalizedUrl}`); - if (response.result?.title) { - console.log(`标题: ${response.result.title}`); + const tab = response.result?.tab; + if (tab) { + console.log(`tab: ${tab}`); } - if (response.result?.tabId) { - console.log(`Tab ID: ${response.result.tabId}`); + console.log(`url: ${response.result?.url ?? normalizedUrl}`); + if (response.result?.title) { + console.log(`title: ${response.result.title}`); } // 提示:如果该域名有 site adapter,引导使用 const siteHint = getSiteHintForDomain(normalizedUrl); if (siteHint) { - console.log(`\n💡 ${siteHint}`); + console.log(`\nhint: ${siteHint}`); } } else { console.error(`错误: ${response.error?.message}`); diff --git a/packages/cli/src/commands/source.ts b/packages/cli/src/commands/source.ts new file mode 100644 index 0000000..1055efd --- /dev/null +++ b/packages/cli/src/commands/source.ts @@ -0,0 +1,58 @@ +/** + * source 命令 - 搜索已加载的 JavaScript 源码 + * + * 用法: + * bb-browser source grep --tab + */ + +import type { Request, Response } from "@bb-browser/shared"; +import { sendCommand } from "../client.js"; + +export interface SourceOptions { + json?: boolean; + tabId?: string | number; +} + +export async function sourceCommand( + subCommand: string, + pattern: string, + options: SourceOptions = {} +): Promise { + const request: Request = { + method: "source", + sourceCommand: subCommand, + sourcePattern: pattern, + tabId: options.tabId, + }; + + const response: Response = await sendCommand(request); + + if (options.json) { + console.log(JSON.stringify(response)); + return; + } + + if (response.error) { + throw new Error(response.error.message || "Source command failed"); + } + + const data = response.result; + const results = (data as any)?.sourceResults || []; + + if (results.length === 0) { + console.log(`No matches found for "${pattern}"`); + return; + } + + let totalMatches = 0; + for (const result of results) { + totalMatches += result.matches.length; + console.log(`=== ${result.url} (${result.matches.length} matches) ===`); + for (const match of result.matches) { + console.log(` ${match}`); + } + console.log(""); + } + + console.log(`${totalMatches} matches in ${results.length} files`); +} diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts index 684194a..87ebb25 100644 --- a/packages/cli/src/commands/trace.ts +++ b/packages/cli/src/commands/trace.ts @@ -13,6 +13,7 @@ interface TraceOptions { filter?: string; limit?: number; requestId?: string; + excludeStatic?: boolean; } export async function traceCommand( @@ -31,6 +32,7 @@ export async function traceCommand( if (options.type) request.traceType = options.type; if (options.filter) request.filter = options.filter; if (options.limit) request.limit = Number(options.limit); + if (options.excludeStatic) request.excludeStatic = true; } // body-specific params @@ -137,10 +139,20 @@ export async function traceCommand( console.error("No body data returned"); break; } - if (body.base64Encoded) { - console.log(`[base64 encoded, ${body.body.length} chars]`); - } else { - console.log(body.body); + if (body.requestBody) { + console.log("=== Request Body ==="); + console.log(body.requestBody); + console.log(""); + } + if (body.body) { + if (body.requestBody) { + console.log("=== Response Body ==="); + } + if (body.base64Encoded) { + console.log(`[base64 encoded, ${body.body.length} chars]`); + } else { + console.log(body.body); + } } break; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7f50e5c..b1f5b3f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ */ import { openCommand } from "./commands/open.js"; +import { gotoCommand } from "./commands/goto.js"; import { snapshotCommand } from "./commands/snapshot.js"; import { clickCommand } from "./commands/click.js"; import { hoverCommand } from "./commands/hover.js"; @@ -24,6 +25,8 @@ import { networkCommand } from "./commands/network.js"; import { consoleCommand } from "./commands/console.js"; import { errorsCommand } from "./commands/errors.js"; import { traceCommand } from "./commands/trace.js"; +import { cookiesCommand } from "./commands/cookies.js"; +import { sourceCommand as sourceCmd } from "./commands/source.js"; import { siteCommand } from "./commands/site.js"; import { shutdownCommand, startCommand, statusCommand } from "./commands/daemon.js"; import { getDaemonPath } from "./daemon-manager.js"; @@ -39,6 +42,7 @@ const TAB_REQUIRED_COMMANDS = new Set([ "click", "hover", "fill", "type", "check", "uncheck", "select", "press", "scroll", "back", "forward", "reload", "close", "frame", "dialog", "network", "console", "errors", + "goto", "cookies", "source", ]); // eval requires --tab unless --domain is provided @@ -67,7 +71,8 @@ bb-browser - AI Agent 浏览器自动化工具 star ⭐ Star bb-browser on GitHub 浏览器操作: - open [--tab] 打开 URL + open [--tab] 打开 URL(新 tab 或指定 tab) + goto --tab 在当前 tab 导航到新 URL(保持上下文) snap [-i] [-c] [-d ] 获取页面快照 (--tab required) click 点击元素 (--tab required) hover 悬停元素 (--tab required) @@ -93,6 +98,8 @@ bb-browser - AI Agent 浏览器自动化工具 调试: network requests [filter] 查看网络请求 (--tab required) + cookies [--filter ] 查看页面 cookies (--tab required) + source grep 搜索已加载 JS 源码 (--tab required) console [--clear] 查看/清空控制台 (--tab required) errors [--clear] 查看/清空 JS 错误 (--tab required) trace start [--tab ] 录制操作+网络的统一时间线 @@ -114,7 +121,8 @@ bb-browser - AI Agent 浏览器自动化工具 --tab 指定操作的标签页 ID --since 增量查询(network/console/errors/trace events) --type 过滤事件类型(trace events: action/request/response) - --filter URL 或文字过滤(trace events/network requests) + --filter URL 或文字过滤(trace events/network requests/cookies) + --exclude-static 排除静态资源(trace events/network requests) --limit 限制返回条数 --request-id 请求 ID(trace body) --help, -h 显示帮助信息 @@ -253,6 +261,8 @@ function parseArgs(argv: string[]): ParsedArgs { if (nextIdx < args.length) { (result.flags as Record)["request-id"] = args[nextIdx]; } + } else if (arg === "--exclude-static") { + (result.flags as Record).excludeStatic = true; } else if (arg.startsWith("-")) { // Unknown flags, ignore } else if (result.command === null) { @@ -329,6 +339,16 @@ async function main(): Promise { break; } + case "goto": { + const gotoUrl = parsed.args[0]; + if (!gotoUrl) { + console.error("用法:bb-browser goto --tab "); + process.exit(1); + } + await gotoCommand(gotoUrl, { json: parsed.flags.json, tabId: globalTabId }); + break; + } + case "snap": { await snapshotCommand({ json: parsed.flags.json, @@ -575,7 +595,8 @@ async function main(): Promise { const method = methodIndex >= 0 ? process.argv[methodIndex + 1] : undefined; const statusIndex = process.argv.findIndex(a => a === "--status"); const statusFilter = statusIndex >= 0 ? process.argv[statusIndex + 1] : undefined; - await networkCommand(subCommand, urlOrFilter, { json: parsed.flags.json, abort, body, withBody, tabId: globalTabId, since: globalSince, method, status: statusFilter }); + const excludeStatic = process.argv.includes("--exclude-static"); + await networkCommand(subCommand, urlOrFilter, { json: parsed.flags.json, abort, body, withBody, tabId: globalTabId, since: globalSince, method, status: statusFilter, excludeStatic }); break; } @@ -591,6 +612,27 @@ async function main(): Promise { break; } + case "cookies": { + const f = parsed.flags as Record; + await cookiesCommand({ + json: parsed.flags.json, + tabId: globalTabId, + filter: f.filter as string | undefined, + }); + break; + } + + case "source": { + const srcSubCmd = parsed.args[0]; + const srcPattern = parsed.args[1]; + if (!srcSubCmd || !srcPattern) { + console.error("用法:bb-browser source grep --tab "); + process.exit(1); + } + await sourceCmd(srcSubCmd, srcPattern, { json: parsed.flags.json, tabId: globalTabId }); + break; + } + case "trace": { const subCmd = parsed.args[0] as 'start' | 'stop' | 'status' | 'events' | 'body' | undefined; if (!subCmd || !['start', 'stop', 'status', 'events', 'body'].includes(subCmd)) { @@ -606,6 +648,7 @@ async function main(): Promise { filter: f.filter as string | undefined, limit: f.limit as number | undefined, requestId: (f["request-id"] as string | undefined) || parsed.args[1], + excludeStatic: f.excludeStatic as boolean | undefined, }); break; } diff --git a/packages/daemon/src/command-dispatch.ts b/packages/daemon/src/command-dispatch.ts index 2049e90..b0b04d3 100644 --- a/packages/daemon/src/command-dispatch.ts +++ b/packages/daemon/src/command-dispatch.ts @@ -498,6 +498,16 @@ async function getAttributeValue( return String(call.result.value ?? ""); } +// --------------------------------------------------------------------------- +// Static asset detection (shared by trace --exclude-static and network --exclude-static) +// --------------------------------------------------------------------------- + +function isStaticAsset(url: string): boolean { + if (url.includes("/_next/static/")) return true; + const ext = url.split("?")[0].split("#")[0].split(".").pop()?.toLowerCase(); + return ["js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot", "map"].includes(ext || ""); +} + // Trace state is now managed by TabStateManager.traceSession // --------------------------------------------------------------------------- @@ -666,6 +676,20 @@ export async function dispatchRequest( }); } + case "goto": { + if (!request.url) return fail("Missing url"); + let url = request.url; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + const seq = tab.recordAction({ action: "goto", url }); + await cdp.pageCommand(target.id, "Page.navigate", { url }); + // Wait briefly for navigation + await new Promise(r => setTimeout(r, 500)); + tab.refs = {}; + return ok({ tab: shortId, seq, url }); + } + case "back": { const seq = tab.recordAction({ action: 'back' }); await cdp.evaluate(target.id, "history.back(); undefined"); @@ -1000,7 +1024,12 @@ export async function dispatchRequest( limit: request.limit, }); - const items = queryResult.items; + let items = queryResult.items; + + // Exclude static assets if requested + if (request.excludeStatic) { + items = items.filter(item => !isStaticAsset(item.url)); + } // Fetch response bodies if requested if (request.withBody) { await Promise.all( @@ -1163,6 +1192,24 @@ export async function dispatchRequest( events = events.filter(e => e.seq > threshold); } + // Exclude static assets (both requests and their responses) + if (request.excludeStatic) { + const excludedRequestIds = new Set(); + events = events.filter((e: TraceEntry) => { + if (e.type === "request") { + if (isStaticAsset(e.url)) { + excludedRequestIds.add(e.requestId); + return false; + } + return true; + } + if (e.type === "response") { + return !excludedRequestIds.has(e.requestId); + } + return true; + }); + } + // Text/URL filter if (request.filter) { const f = request.filter.toLowerCase(); @@ -1204,11 +1251,31 @@ export async function dispatchRequest( "Network.getResponseBody", { requestId: request.requestId }, ); + // Also include request body from trace timeline if available + const traceRequestBody = reqEntry.body; return ok({ - traceBody: { requestId: request.requestId, body: body.body, base64Encoded: body.base64Encoded }, + traceBody: { + requestId: request.requestId, + body: body.body, + base64Encoded: body.base64Encoded, + requestBody: traceRequestBody, + }, tab: shortId, }); } catch (error) { + // Even if response body fetch fails, try to return the request body + const traceRequestBody = reqEntry.body; + if (traceRequestBody) { + return ok({ + traceBody: { + requestId: request.requestId, + body: "", + base64Encoded: false, + requestBody: traceRequestBody, + }, + tab: shortId, + }); + } return fail(error); } } @@ -1217,6 +1284,100 @@ export async function dispatchRequest( } } + // ----------------------------------------------------------------------- + // Cookies + // ----------------------------------------------------------------------- + case "cookies": { + const seq = tab.recordAction({ action: "cookies" }); + const { cookies } = await cdp.sessionCommand<{ cookies: any[] }>(target.id, "Network.getCookies", {}); + let filtered = cookies; + if (request.filter) { + const f = request.filter.toLowerCase(); + filtered = cookies.filter((c: any) => + c.name.toLowerCase().includes(f) || c.domain.toLowerCase().includes(f) + ); + } + return ok({ + tab: shortId, + seq, + cookies: filtered.map((c: any) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + })), + }); + } + + // ----------------------------------------------------------------------- + // Source search + // ----------------------------------------------------------------------- + case "source": { + const subCmd = request.sourceCommand; + if (subCmd !== "grep") return fail("Unknown source subcommand. Use: source grep "); + const pattern = request.sourcePattern; + if (!pattern) return fail("Missing search pattern"); + + const seq = tab.recordAction({ action: "source", url: `grep ${pattern}` }); + + // Get all frames and their resources + const { frameTree } = await cdp.sessionCommand(target.id, "Page.getResourceTree", {}); + + // Collect all script URLs from the resource tree + const scripts: { url: string; frameId: string }[] = []; + function collectScripts(node: any) { + if (node.resources) { + for (const r of node.resources) { + if (r.type === "Script" && r.url && !r.url.startsWith("data:")) { + scripts.push({ url: r.url, frameId: node.frame.id }); + } + } + } + if (node.childFrames) { + for (const child of node.childFrames) collectScripts(child); + } + } + collectScripts(frameTree); + + // Search each script + const sourceResults: { url: string; matches: string[] }[] = []; + for (const script of scripts) { + try { + const { content } = await cdp.sessionCommand<{ content: string }>( + target.id, "Page.getResourceContent", + { frameId: script.frameId, url: script.url } + ); + if (!content) continue; + + // Find all lines containing the pattern + const regex = new RegExp(pattern, "gi"); + const lines = content.split("\n"); + const matches: string[] = []; + for (const line of lines) { + if (regex.test(line)) { + // Extract a short context around the match (trim to 200 chars) + const trimmed = line.trim().slice(0, 200); + matches.push(trimmed); + regex.lastIndex = 0; // Reset regex state + } + } + if (matches.length > 0) { + // Shorten URL for display + const shortUrl = script.url.replace(/^https?:\/\/[^/]+/, ""); + sourceResults.push({ url: shortUrl, matches }); + } + } catch { + // Skip scripts that can't be fetched (e.g., cross-origin) + continue; + } + } + + return ok({ tab: shortId, seq, sourceResults }); + } + // ----------------------------------------------------------------------- // Site adapters // ----------------------------------------------------------------------- diff --git a/packages/shared/src/commands.ts b/packages/shared/src/commands.ts index 5169908..c5d54a6 100644 --- a/packages/shared/src/commands.ts +++ b/packages/shared/src/commands.ts @@ -49,6 +49,15 @@ export const COMMANDS: CommandDef[] = [ tab: { type: "string", required: false, description: "Tab short ID to navigate in (omit to open in a new tab)" }, }, }, + { + method: "goto", group: "navigate", + description: "Navigate current tab to a URL (keeps tab context)", + requiresTab: true, + params: { + url: { type: "string", required: true, position: 0, description: "URL to navigate to" }, + tab: { type: "string", required: false, description: "Tab ID" }, + }, + }, { method: "back", group: "navigate", description: "Navigate back in browser history", @@ -315,6 +324,7 @@ export const COMMANDS: CommandDef[] = [ status: { type: "string", required: false, description: "Filter by status: '4xx', '5xx', or exact code like '200'" }, limit: { type: "number", required: false, description: "Max number of results to return" }, withBody: { type: "boolean", required: false, description: "Include request and response bodies" }, + excludeStatic: { type: "boolean", required: false, description: "Exclude static assets (.js, .css, .png, etc.)" }, tab: { type: "string", required: true, description: "Tab short ID" }, }, }, @@ -354,6 +364,26 @@ export const COMMANDS: CommandDef[] = [ filter: { type: "string", required: false, description: "URL or text substring filter" }, limit: { type: "number", required: false, description: "Max number of events to return" }, requestId: { type: "string", required: false, description: "Request ID (for trace body)" }, + excludeStatic: { type: "boolean", required: false, description: "Exclude static assets (.js, .css, .png, etc.)" }, + }, + }, + { + method: "cookies", group: "debug", + description: "List cookies for the current page", + requiresTab: true, + params: { + tab: { type: "string", required: false, description: "Tab ID" }, + filter: { type: "string", required: false, description: "Filter by cookie name or domain" }, + }, + }, + { + method: "source", group: "debug", + description: "Search loaded JavaScript sources (e.g. source grep '/api/')", + requiresTab: true, + params: { + sourceCommand: { type: "string", required: true, position: 0, description: "Subcommand: grep" }, + sourcePattern: { type: "string", required: true, position: 1, description: "Search pattern (string or regex)" }, + tab: { type: "string", required: false, description: "Tab ID" }, }, }, ]; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index e8e80fb..bf37af2 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -34,7 +34,10 @@ export type ActionType = | "site_run" | "site_list" | "site_info" - | "site_search"; + | "site_search" + | "goto" + | "cookies" + | "source"; /** 请求类型 */ export interface Request { @@ -121,6 +124,12 @@ export interface Request { query?: string; /** 是否包含 base64 数据(screenshot 命令使用) */ includeBase64?: boolean; + /** 排除静态资源(trace events / network requests 使用) */ + excludeStatic?: boolean; + /** source 子命令:grep */ + sourceCommand?: string; + /** source 搜索模式 */ + sourcePattern?: string; } /** 元素引用信息 */ @@ -350,7 +359,11 @@ export interface ResponseData { /** Trace session status */ traceStatus?: TraceStatus; /** Trace response body (trace body command) */ - traceBody?: { requestId: string; body: string; base64Encoded: boolean }; + traceBody?: { requestId: string; body: string; base64Encoded: boolean; requestBody?: string }; + /** Cookies for the current page (cookies command) */ + cookies?: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>; + /** Source grep results (source command) */ + sourceResults?: Array<{ url: string; matches: string[] }>; } /** 错误信息 */