关于 gnews
通过用户浏览器搜索 Google 新闻结果并提取结构化文章信息的命令行工具。它提供发布方直达链接,支持 JSON 输出和并行搜索。
主要功能
- 01搜索 Google 新闻结果
- 02提取标题、链接、来源、摘要和时间
- 03输出格式化文本或 JSON
- 04并行执行相互独立的搜索
- 05通过 browser-harness-js 打开文章链接
适用场景
- 01收集某主题的近期报道
- 02将新闻结果传给 JSON 处理脚本
- 03并发搜索多个查询
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ monotykamary/ browser-harness-js --skill gnews安装后在 Claude Code 中输入/gnews确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
scripts/gnewssetuptest详细介绍
Google News
通过 CDP 搜索 Google News 并提取结构化结果。通过用户自己的浏览器访问 Google Search 的新闻标签页(tbm=nws),因此提取依据的是渲染后的页面,而不是原始 fetch。除提供 CDP 会话的 browser-harness-js 外,无其他外部依赖。每次调用都会打开自己的标签页,并使用每次调用专属的 sessionId,可安全并行使用。
快速搜索
gnews "your query" # pretty-printed, up to 10 results
gnews "your query" 5 # 5 results, pretty-printed
gnews --json "your query" 3 # raw JSON
并行使用
每次 gnews 调用都会复用共享 WebSocket,但会使用每次调用专属的 sessionId 连接到自己的标签页。针对特定标签页的 CDP 调用通过 cdp(sessionId, method, params) 执行。多次调用可以并发运行,互不干扰——不会覆盖 activeSessionId、不会相互干扰标签页,也不会让事件串流。标签页通过 Target.closeTarget 以即发即弃的方式关闭,因此调用方无需等待清理完成。
gnews "rust async" 3 &
gnews "go channels" 3 &
wait
结果结构
每个结果的格式为 { title, url, source, snippet, time }:
[
{
"title": "Meta jumps into AI coding market in effort to chase Anthropic and OpenAI",
"url": "https://www.cnbc.com/2026/07/09/meta-jumps-into-ai-coding-market.html",
"source": "CNBC",
"snippet": "Meta is upgrading its Muse Spark artificial intelligence models…",
"time": "21 hours ago"
}
]
url 是发布方的直接链接——tbm=nws 返回真实文章 URL,而不是 news.google.com/articles/<id> 重定向包装链接。对于 Google 渲染时没有描述的卡片,snippet 为空;time 是 Google 的相对时间字符串(例如 2 hours ago),使用浏览器的界面语言区域设置。
--json 模式始终会输出有效的 JSON:无结果的查询会返回 [](脚本会将数组本身转换为字符串,而不是返回数组,因此 REPL 将空数组渲染为 '' 时,不会导致传给 jq / JSON.parse 的内容不是有效 JSON)。
跟进结果链接
通过 browser-harness-js 直接打开结果 url,无需重新搜索。流程与临时搜索相同,都是连接、创建标签页并执行 evaluate,但这里会导航至文章并提取页面文本:
browser-harness-js <<'EOF'
if (!session.isConnected()) {
try { await session.connect() } catch (e) { throw new Error("Cannot connect: " + e.message) }
}
const url = "https://www.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(没有正在进行的网络请求持续 500ms),这是内容页面的合适默认值:它会在load后触发,因此至少能返回同等数量的内容;而且它不会像loadEventFired那样被一直挂起的广告或分析信标阻塞。针对特定页面类型,也可以选择其他策略:networkAlmostIdle(250ms 静默窗口)——适用于持续进行 XHR 轮询、始终无法达到完整 500ms 静默窗口的页面。loadEventFired——适用于确实需要加载所有子资源的情况(文本提取中较少见)。- evaluate 前,在页面就绪后执行一个较短的
await new Promise(r => setTimeout(r, 1000))——适用于在networkIdle后才延迟渲染内容的页面(例如 SPA 水合、延迟加载的图片包)。许多发布方网站会延迟加载文章正文,因此通常值得在这里额外等待 1 秒。
不使用脚本进行临时搜索
如果 gnews 不在 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") + "&tbm=nws&num=" + count
})
await ready
const expression = `(() => {
const ext = a => { try { return !/^(www\\.)?google\\./.test(new URL(a.href).hostname) } catch { return false } };
const cards = [...document.querySelectorAll("a[href]")].filter(a => a.querySelector("div[role=heading]") && ext(a));
const ex = a => {
const title = (a.querySelector("div[role=heading]")?.textContent || "").trim();
let lines = (a.innerText || "").replace(/\\u00a0/g, " ").split("\\n").map(s => s.trim()).filter(s => s.length > 0);
let source = "";
let rest = lines.slice();
if (rest[0] && rest[0] !== title) { source = rest[0]; rest = rest.slice(1); }
if (rest[0] === title) rest = rest.slice(1);
const dot = rest.indexOf(".");
let snippet = "", time = "";
if (dot >= 0) { snippet = rest.slice(0, dot).join(" ").trim(); time = rest.slice(dot + 1).join(" ").trim(); }
else { time = rest.length ? rest.join(" ").trim() : ""; }
return { title, url: a.href, source, snippet, time };
};
return cards.slice(0, 10).map(ex);
})()`
const result = await cdp(sessionId, "Runtime.evaluate", { expression, returnByValue: true })
const results = JSON.parse(result.result.value)
return results.map(r => r.title + "\n " + r.url + "\n " + [r.source, r.time].filter(Boolean).join(" · ") + (r.snippet ? "\n " + r.snippet : "")).join("\n\n")
} finally {
session.closeTab(t.targetId, sessionId).catch(() => {})
}
EOF
工作原理
| 步骤 | CDP 调用 | 功能 |
|---|---|---|
| 1 | session.connect()(一次) | 将共享 WebSocket 连接到浏览器 |
| 2 | Target.createTarget({ background: true }) | 创建一个隔离的后台标签页 |
| 3 | Target.attachToTarget | 获取该标签页专属的 sessionId,用于按标签页路由 |
| 4 | cdp(sessionId, "Page.enable", …) | 订阅页面事件 |
| 5 | cdp(sessionId, "Page.setLifecycleEventsEnabled", …) | 启用生命周期事件——没有此设置就不会触发 networkIdle |
| 6 | 在 cdp(sessionId, "Page.navigate", …) 之前先启用 session.waitFor('Page.lifecycleEvent' networkIdle) | 修复竞态问题:在导航前启用 networkIdle 等待(避免页面加载事件已触发的竞态),然后前往 google.com/search?q=…&tbm=nws&num=N(在 JS 中通过 encodeURIComponent 进行 URI 编码) |
| 7 | cdp(sessionId, "Runtime.evaluate", …) | 通过单次 DOM 查询提取所有结果 |
| 8 | closeTab(即发即弃) | 关闭标签页,不阻塞响应 |
每次调用耗时约 2–3 秒(主要耗在等待 networkIdle 上)。共享 WebSocket 意味着无需反复弹出权限提示。URI 编码和输出格式化在 JS 中完成——不依赖 jq。
为什么选择 tbm=nws(新闻标签页),而不是 news.google.com
专用的 Google News 应用(news.google.com/search?q=…)是一个庞大的 React SPA,其结果链接会被包装在 news.google.com/articles/<id> 重定向中——要获取真实的发布方 URL,必须逐个跟随重定向。Google Search 的新闻标签页(google.com/search?…&tbm=nws)是同一个 Google News 垂直搜索,但由经典结果主机提供:它的生命周期与普通搜索相同(因此 networkIdle 的工作方式与 gsearch 中相同),且每个结果的 href 都是发布方的直接 URL。这就是 gnews 采用 tbm=nws 的原因:SPA 水合更少、没有重定向包装,并且导航并等待的模式与 gsearch 相同。
为什么选择 Runtime.evaluate,而不是无障碍树
与 gsearch 一样,使用一次 Runtime.evaluate 和 querySelectorAll 就能在一次 CDP 调用中返回所有结果(约 5ms),比遍历 Google 1000 多个节点的 AX 树并逐节点查找父级便宜得多。提取过程刻意避开 Google 混淆过的类名(WlydOe、sjVJQd……),因为这些类名经常变动:它会保留满足以下条件的 <a> 锚点:(a)包含 div[role=heading];(b)链接指向 Google 之外。这些依据都是结构性的,而非基于类名,因此类名更改不会导致提取失效。
解析卡片文本
每个结果卡片的 innerText 按行读取:
<source>
<title>
[<snippet>]
.
<time>
标题从 div[role=heading] 读取(这是稳定的语义钩子)。单独的 . 行是 Google 在摘要和末尾的相对时间之间使用的分隔符——它只会在卡片有摘要时出现,因此解析器会以它为界拆分:它之前的文本是摘要,之后的文本是时间。没有摘要的卡片则只有“来源 / 标题 / 时间”。
不依赖 jq
URI 编码使用 JS 中的 encodeURIComponent(),输出格式则通过 heredoc 中的 .map().join() 完成。原始查询会注入带引号的 heredoc,作为 __GNEWS_QUERY__ 占位符,再由 node 使用 JSON.stringify 改写——因此查询中的 &、$、反引号、引号和非 ASCII 字符都不需要手动转义;函数替换(c.replace(/__X__/g, () => JSON.stringify(v)))则避开了 bash ${var//} 和 JS String.replace 对普通替换字符串处理 &/$ 时都会采用的语义。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,因此页面卡住时不会导致标签页泄漏。新闻标签页是纯服务端渲染的 HTML,因此networkIdle会可靠触发;如果某个区域版本一直轮询,请参阅“Following a result link”中的networkAlmostIdle。 - 新闻标签页不会分页。 每页大约返回 10–15 条结果,并会忽略超出该数量的
num=。请求gnews "q" 30返回的是页面实际加载的内容(在 JS 中最多为你的count),而不是 30 条 — 不会抓取下一页。 - 结果数量可能少于请求数量 — Google 有时返回的结果少于
num=,突发新闻查询在请求较慢时也可能返回 0 条。 - Google 可能会在某些地区显示同意/ Cookie 提示页 — 这会导致返回 0 条结果。如果结果为空,请通过截图检查。
- 新闻标签页上的标题不是
<h3>— 与经典搜索结果(gsearch)不同,新闻结果会将标题放在<div role="heading">中。提取逻辑依据role=heading,而不是h3。 - 多语句 heredoc 需要
return—browser-harness-js只会自动返回单个表达式。