mcpField
G
gnews

gnews

github.com/monotykamary生产力449 次安装2 天前更新
代码仓库

关于 gnews

通过用户浏览器搜索 Google 新闻结果并提取结构化文章信息的命令行工具。它提供发布方直达链接,支持 JSON 输出和并行搜索。

主要功能

  1. 01搜索 Google 新闻结果
  2. 02提取标题、链接、来源、摘要和时间
  3. 03输出格式化文本或 JSON
  4. 04并行执行相互独立的搜索
  5. 05通过 browser-harness-js 打开文章链接

适用场景

  1. 01收集某主题的近期报道
  2. 02将新闻结果传给 JSON 处理脚本
  3. 03并发搜索多个查询

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/monotykamary/browser-harness-js --skill gnews

安装后在 Claude Code 中输入/gnews确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

scripts/
gnews
setup
test

详细介绍

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 调用功能
1session.connect()(一次)将共享 WebSocket 连接到浏览器
2Target.createTarget({ background: true })创建一个隔离的后台标签页
3Target.attachToTarget获取该标签页专属的 sessionId,用于按标签页路由
4cdp(sessionId, "Page.enable", …)订阅页面事件
5cdp(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 编码)
7cdp(sessionId, "Runtime.evaluate", …)通过单次 DOM 查询提取所有结果
8closeTab(即发即弃)关闭标签页,不阻塞响应

每次调用耗时约 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 只会自动返回单个表达式。

评论

支持 Markdown