关于 gsearch
gsearch 是通过 CDP 搜索 Google 并提取结构化结果的命令行工具,适合需要在浏览器会话中检索和阅读网页的工作流。它支持并行搜索、直接跟进结果链接,并可输出 JSON。
主要功能
- 01搜索 Google 并提取标题、网址和摘要
- 02以格式化文本或 JSON 输出结果
- 03并行运行互不干扰的搜索
- 04打开结果链接并提取网页文本
- 05支持选择器、等待策略及 JSON 页面
适用场景
- 01并行查询多个技术主题
- 02搜索并阅读网页文章内容
- 03提取 JSON 端点的响应内容
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ monotykamary/ browser-harness-js --skill gsearch安装后在 Claude Code 中输入/gsearch确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
scripts/gsearchsetuptest详细介绍
Google 搜索
通过 CDP 搜索 Google 并提取结构化结果。除 browser-harness-js(用于提供 CDP 会话)外,无需其他外部依赖。每次调用都会打开自己的标签页和 WebSocket 会话,可安全并行使用。
快速搜索
gsearch "your query" # pretty-printed, up to 10 results
gsearch "your query" 5 # 5 results, pretty-printed
gsearch --json "your query" 3 # raw JSON
并行使用
每次 gsearch 调用都会复用共享 WebSocket,但会通过独立的 sessionId 连接到自己的标签页。针对特定标签页的 CDP 调用通过 cdp(sessionId, method, params) 进行。多个调用可以并发运行,互不干扰——不会覆盖 activeSessionId,不会互相操作标签页,也不会交叉触发事件。标签页通过 Target.closeTarget 以即发即弃的方式关闭,因此调用方无需等待清理完成。
gsearch "rust async" 3 &
gsearch "go channels" 3 &
wait
结果格式
每条结果的格式为 { title, url, snippet }:
[
{
"title": "TypeScript 5.8",
"url": "https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html",
"snippet": "TypeScript 5.8 introduces a stable --module node18 flag..."
}
]
跟进结果链接
gsearch follow <url> 可直接打开结果链接,无需重新搜索或经由 Google 跳转,并返回页面的可读文本;使用 --json 时则返回解析后的 JSON。它封装了连接、创建标签页、导航、等待和求值的流程,因此你无需为每个链接手动编写这些步骤(也无需使用会被设有机器人防护墙的网站拒绝的 curl):
gsearch follow https://example.com/some-article # readable text (article/main/body)
gsearch follow https://example.com/some-article --selector "pre" # custom CSS selector
gsearch follow https://example.com/some-article --settle 2000 # extra ms for lazy/SPA content
gsearch follow https://example.com/some-article --wait almostIdle # networkIdle (default) | almostIdle | load
gsearch follow https://example.com/api.json --json # URL is JSON: poll, parse, print JSON
标志可以放在 URL 前面或后面。默认选择器为 article, main, [role=main];--json 会切换为下方的 JSON 处理方式。
底层原理(手动操作)
通过 browser-harness-js 直接打开结果 url——无需重新搜索或经由 Google 跳转。流程与临时搜索相同,都是连接、创建标签页并执行求值,但这里会导航到链接并提取页面文本:
browser-harness-js <<'EOF'
if (!session.isConnected()) {
try { await session.connect() } catch (e) { throw new Error("Cannot connect: " + e.message) }
}
const url = "https://example.com/some-article"
const t = await session.Target.createTarget({ url: "about:blank", background: true })
const { sessionId } = await session.Target.attachToTarget({ targetId: t.targetId, flatten: true })
try {
await cdp(sessionId, "Page.enable", {})
// Required — without this Chrome emits zero Page.lifecycleEvent, so networkIdle
// would never fire.
await cdp(sessionId, "Page.setLifecycleEventsEnabled", { enabled: true })
// Arm the wait BEFORE Page.navigate: lifecycle events fire once, and a fast
// load can fire networkIdle between navigate returning and the listener subscribing.
const ready = session.waitFor({ method: 'Page.lifecycleEvent', sessionId, predicate: (p) => p.name === 'networkIdle', timeoutMs: 30_000 })
await cdp(sessionId, "Page.navigate", { url })
await ready
const result = await cdp(sessionId, "Runtime.evaluate", {
expression: 'document.querySelector("article, main")?.innerText || document.body.innerText',
returnByValue: true
})
return result.result.value
} finally {
session.closeTab(t.targetId, sessionId).catch(() => {})
}
EOF
article, main会跳过导航栏和页脚等页面框架;若无法匹配,则使用document.body.innerText。- 等待策略。 示例会等待
networkIdle(500 毫秒内没有正在进行的网络请求)——这是内容页面的合适默认值:它会在load之后触发,因此至少能返回同等数量的内容;而且不像loadEventFired那样会被挂起的广告或分析信标阻塞。针对特定页面类型的替代方案:networkAlmostIdle(250 毫秒的静默窗口)——适用于持续进行 XHR 轮询、始终无法达到完整 500 毫秒静默期的页面。loadEventFired——确实需要加载所有子资源时使用(文本提取很少需要)。- 求值前,在页面就绪后短暂等待:
await new Promise(r => setTimeout(r, 1000))——适用于在networkIdle之后才延迟渲染内容的页面(例如 SPA 水合、延迟加载的图片包)。
- JSON 端点(
--json):导航到application/json时不会触发任何Page生命周期事件,而且 Chrome 的 JSON 查看器会按自己的时间安排将正文渲染到document.body.innerText。不要使用waitFor('networkIdle', …)(会一直挂起),也不要在固定时间读取一次innerText(查看器完成前会得到''或不完整、被截断的内容)。gsearch follow --json会轮询innerText,直到JSON.parse成功(轮询本身就是响应头验证),如果响应实际为text/html(错误页面、登录墙或反机器人验证页面),则会提前退出。有关原始处理方式和反机器人fetch()陷阱,请参阅cdp技能中的 json-navigation.md。
不使用脚本进行临时搜索
如果 gsearch 不在 PATH 中,也可以通过 browser-harness-js 直接运行相同逻辑:
browser-harness-js <<'EOF'
if (!session.isConnected()) {
try { await session.connect() } catch (e) { throw new Error("Cannot connect: " + e.message) }
}
const count = 10
const t = await session.Target.createTarget({ url: "about:blank", background: true })
const { sessionId } = await session.Target.attachToTarget({ targetId: t.targetId, flatten: true })
try {
await cdp(sessionId, "Page.enable", {})
await cdp(sessionId, "Page.setLifecycleEventsEnabled", { enabled: true })
const ready = session.waitFor({ method: 'Page.lifecycleEvent', sessionId, predicate: (p) => p.name === 'networkIdle', timeoutMs: 30_000 })
await cdp(sessionId, "Page.navigate", {
url: "https://www.google.com/search?q=" + encodeURIComponent("your query") + "&num=" + count
})
await ready
const result = await cdp(sessionId, "Runtime.evaluate", {
expression: 'JSON.stringify([...document.querySelectorAll(".tF2Cxc")].slice(0, 10).map(el => ({ title: el.querySelector("h3")?.textContent?.trim() || "", url: el.querySelector("a[href]")?.href || "", snippet: el.querySelector(".VwiC3b")?.textContent?.trim() || "" })))',
returnByValue: true
})
const results = JSON.parse(result.result.value)
return results.map(r => r.title + "\n " + r.url + "\n " + r.snippet).join("\n\n")
} finally {
session.closeTab(t.targetId, sessionId).catch(() => {})
}
EOF
工作原理
| 步骤 | CDP 调用 | 功能 |
|---|---|---|
| 1 | session.connect() (once) | 将共享 WebSocket 连接到浏览器 |
| 2 | Target.createTarget({ background: true }) | 创建隔离的后台标签页 |
| 3 | Target.attachToTarget | 获取用于标签页范围路由的每次调用专属 sessionId |
| 4 | cdp(sessionId, "Page.enable", …) | 订阅页面事件 |
| 5 | cdp(sessionId, "Page.setLifecycleEventsEnabled", …) | 启用生命周期事件——未启用时不会触发 networkIdle |
| 6 | session.waitFor('Page.lifecycleEvent' networkIdle) 在 cdp(sessionId, "Page.navigate", …) 之前启动 | 修复竞态:在导航前启动 networkIdle 等待(避免页面加载已完成的竞态),然后访问 google.com/search?q=…&num=N(在 JS 中通过 encodeURIComponent 进行 URI 编码) |
| 7 | cdp(sessionId, "Runtime.evaluate", …) | 通过单次 DOM 查询提取所有结果 |
| 8 | closeTab(即发即弃) | 关闭标签页,不阻塞响应 |
每次调用耗时约 2–3 秒(主要耗在等待 networkIdle 上)。共享 WebSocket 无需反复弹出权限提示。URI 编码和输出格式化都在 JS 中完成——不依赖 jq。
为什么使用 Runtime.evaluate 而不是无障碍树
Google 搜索页面的 AX 树有 1300 多个节点——遍历它需要逐个查询节点的父节点来重建结果层级。使用一次 Runtime.evaluate 调用 querySelectorAll('.tF2Cxc'),即可通过单次 CDP 调用返回相同数据(约 5 ms,而 AX 树遍历约需 200 ms)。CSS 选择器(用于结果容器的 .tF2Cxc、用于标题的 h3、用于摘要的 .VwiC3b)在 Google 当前的 HTML 结构中保持稳定。
不依赖 jq
URI 编码在 JS 中使用 encodeURIComponent(),输出格式化则通过 heredoc 中的 .map().join() 完成。使用 sed 对原始查询进行转义,以便插入 JS 字符串(bash 中转义反斜杠、$ 和反引号;JS 中转义单引号)。REPL 的 renderResult 会直接传递字符串返回值,不会套上 JSON,因此 bash 只需打印即可。
注意事项
- 使用
try/finally和即发即弃的closeTab清理标签页——closeTab会执行window.close()和Target.closeTarget,以彻底清理;它被封装在finally中,因此即使发生错误也会运行。该调用不会等待完成,因此不会阻塞响应。快速并行调用时,关闭操作会在会话的closeQueue中排队执行,但不会阻塞结果。 - 每个新标签页都必须调用
Page.enable()和Page.setLifecycleEventsEnabled({ enabled: true })。后者是 Chrome 发出任何Page.lifecycleEvent的必要条件——不调用时,networkIdle等待每次都会超时。 networkIdle等待有 30s 超时——使用session.waitFor()而不是原始 Promise,因此页面卡住时不会导致标签页泄漏。持续进行 XHR 轮询的页面可能永远无法达到 500 ms 的静默窗口——若要使用networkAlmostIdle作为备用方案,请参阅“Following a result link”中的等待策略说明。- 结果数量可能少于
num=指定的数量——Google 有时返回的结果会少于请求数量。 - Google 在某些地区可能会显示同意/ Cookie 墙——这会返回 0 条结果,与旧方案相同。如果结果为空,请用截图确认。
- 多语句 heredoc 需要使用
return——browser-harness-js仅会自动返回单个表达式。