gnews について
ユーザーのブラウザー経由で Google のニュース検索を行い、記事情報を構造化して抽出するコマンドラインツールです。記事の配信元への直接リンクを返し、JSON 出力と並列検索に対応します。
主な機能
- 01Google ニュース検索
- 02タイトル、URL、媒体、抜粋、時刻を抽出
- 03整形テキストまたは JSON を出力
- 04独立した検索を並列実行
- 05browser-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 ニュース
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 呼び出し | 内容 |
|---|---|---|
| 1 | session.connect()(1 回) | 共有 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=…&tbm=nws&num=N に移動します(JS の encodeURIComponent で URI エンコードします) |
| 7 | cdp(sessionId, "Runtime.evaluate", …) | DOM を 1 回クエリして、すべての結果を抽出します |
| 8 | closeTab(応答を待たずに実行) | 応答をブロックせずにタブを閉じます |
各呼び出しにかかる時間は約 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 するのは単一の式のみです。