mcpField
G
gnews

gnews

github.com/monotykamary生産性449 回インストール2 日前に更新
リポジトリ

gnews について

ユーザーのブラウザー経由で Google のニュース検索を行い、記事情報を構造化して抽出するコマンドラインツールです。記事の配信元への直接リンクを返し、JSON 出力と並列検索に対応します。

主な機能

  1. 01Google ニュース検索
  2. 02タイトル、URL、媒体、抜粋、時刻を抽出
  3. 03整形テキストまたは JSON を出力
  4. 04独立した検索を並列実行
  5. 05browser-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 ニュース

Google ニュースを検索し、CDP 経由で構造化された結果を抽出します。ユーザー自身のブラウザーを使って Google 検索のニュースタブ(tbm=nws)にアクセスするため、生のフェッチではなく、レンダリングされたページをもとに抽出します。依存関係は 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 は news.google.com/articles/<id> へのリダイレクト用ラッパーではなく、実際の記事 URL を返します。Google が説明文なしで表示するカードでは snippet は空になり、time はブラウザーの UI 言語で表示される Google の相対時間文字列(例:2 hours ago)です。

--json モードは常に有効な JSON を出力します。検索結果が 0 件の場合は [] を返します(スクリプトは配列そのものではなく、配列を文字列化して返すため、REPL で空配列が '' と表示され、jq/JSON.parse の呼び出し元に JSON 以外のペイロードが渡ることはありません)。

結果リンクをたどる

結果の url を browser-harness-js で直接開きます。再検索は不要です。アドホック検索と同じ接続/タブ作成/評価の流れで、記事に移動してページテキストを抽出します。

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 の静止期間):500ms の静止期間に一度も達しない、継続的な XHR ポーリングがあるページ向けです。
    • loadEventFired:すべてのサブリソースが読み込まれる必要が本当にある場合に使います(テキスト抽出ではまれです)。
    • 評価の前に、準備完了後の短い待機として 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()(1 回)共有 WebSocket をブラウザーに接続します
2Target.createTarget({ background: true })分離されたバックグラウンドタブを作成します
3Target.attachToTargetタブ単位のルーティングに使う、呼び出しごとの sessionId を取得します
4cdp(sessionId, "Page.enable", …)ページイベントを購読します
5cdp(sessionId, "Page.setLifecycleEventsEnabled", …)ライフサイクルイベントを有効にします。有効にしないと networkIdle は発生しません
6session.waitFor('Page.lifecycleEvent' networkIdle) を cdp(sessionId, "Page.navigate", …) の前に設定競合状態を回避します。ナビゲーション前に networkIdle の待機を設定し(読み込み完了後に待機を開始する競合を防止)、その後 google.com/search?q=…&tbm=nws&num=N に移動します(JS の encodeURIComponent で URI エンコードします)
7cdp(sessionId, "Runtime.evaluate", …)DOM を 1 回クエリして、すべての結果を抽出します
8closeTab(応答を待たずに実行)応答をブロックせずにタブを閉じます

各呼び出しにかかる時間は約 2~3 秒で、その大半は networkIdle の待機時間です。共有 WebSocket を使うため、権限確認のポップアップが繰り返し表示されることはありません。URI エンコードと出力の整形は JS で行うため、jq への依存はありません。

news.google.com ではなく tbm=nws(ニュースタブ)を使う理由

専用の Google News アプリ(news.google.com/search?q=…)は、結果へのリンクが news.google.com/articles/<id> のリダイレクトでラップされた、重い React SPA です。実際のパブリッシャー URL を取得するには、各リダイレクトをたどる必要があります。Google 検索のニュースタブ(google.com/search?…&tbm=nws)は、Google ニュースの同じ縦型検索結果ですが、従来の検索結果ホストから提供されます。通常の検索と同じライフサイクルでレンダリングされるため、networkIdle は gsearch と同じように機能し、各結果の href はパブリッシャーの直接 URLです。これが gnews が tbm=nws を対象にする理由です。SPA のハイドレーションが少なく、リダイレクトでラップされず、gsearch と同じ移動して待機する形式を使えます。

アクセシビリティツリーではなく Runtime.evaluate を使う理由

gsearch と同様に、querySelectorAll を使った単一の Runtime.evaluate で、すべての結果を 1 回の CDP 呼び出し(約 5ms)で取得できます。これは、ノードごとに親を検索しながら Google の 1000 個を超えるノードからなる AX ツリーをたどるより、はるかに低コストです。抽出処理では、変わりやすい Google の難読化されたクラス名(WlydOe、sjVJQd、…)を意図的に避けています。代わりに、(a)div[role=heading] を含み、(b)Google 外にリンクする <a> アンカーを対象にします。どちらもクラス名ではなく構造に基づくため、クラス名が変更されても壊れません。

カードテキストの解析

各結果カードの innerText は、行ごとに次のようになります。

<source>
<title>
[<snippet>]
.
<time>

タイトルは div[role=heading](安定したセマンティックなフック)から取得します。単独の . の行は、スニペットと末尾の相対時間の間にある Google の区切りです。これはカードにスニペットがある場合にのみ存在するため、パーサーはこれを境に分割します。前のテキストはスニペット、後のテキストは時刻です。スニペットがないカードは、source / title / time の形式です。

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 はそのまま出力できます。

注意点

  • タブのクリーンアップでは、fire-and-forget の closeTab を try/finally で呼び出します — closeTab は window.close() と Target.closeTarget を実行して確実にクリーンアップし、エラー時にも実行されるよう finally で囲んでいます。レスポンスをブロックしないよう、呼び出しは await しません。短時間に並列呼び出しが行われると、クローズ処理はセッションの closeQueue で順番に実行されますが、結果をブロックすることはありません。
  • 新しいタブごとに、Page.enable() と Page.setLifecycleEventsEnabled({ enabled: true }) の両方を呼び出す必要があります。Chrome が Page.lifecycleEvent を出力するには、後者が必要です。呼び出さないと、networkIdle の待機は毎回タイムアウトします。
  • networkIdle の待機には 30s のタイムアウトがあります — 生の promise ではなく session.waitFor() を使用するため、応答しないページがタブをリークすることはありません。ニュースタブは通常のサーバー描画 HTML なので、networkIdle は確実に発生します。地域別のバリエーションでポーリングが止まらない場合は、「結果リンクをたどる」の networkAlmostIdle を参照してください。
  • ニュースタブにはページ送りがありません。 1 ページあたり約 10~15 件の結果を返し、それを超える num= は無視します。gnews "q" 30 をリクエストしても、ページが読み込んだ件数(JS では count を上限とする)が返されるだけで、30 件にはなりません。次のページを取得する処理はありません。
  • 結果の件数がリクエストした件数より少ない場合があります — Google が num= より少ない結果しか返さないことがあり、ニュース速報のクエリでは、更新のタイミングが遅いと 0 件になることがあります。
  • 地域によっては Google が同意/Cookie の壁を表示することがあります — この場合、結果は 0 件になります。結果が空の場合は、スクリーンショットで確認してください。
  • ニュースタブではタイトルは <h3> ではありません — 従来の検索結果(gsearch)とは異なり、ニュースの検索結果ではタイトルが <div role="heading"> に格納されています。抽出では h3 ではなく role=heading を基準にします。
  • 複数のステートメントを含む heredoc には return が必要です — browser-harness-js が自動的に return するのは単一の式のみです。

コメント