長い問いかけの検索語を、Search Console APIで抜き出して記事の見出しと突き合わせる。正規表現(RE2)での絞り込み、依頼文・疑問・文・語の並びに分けるNodeのスクリプト、匿名化で見えない分の数え方
AIに
この
この
この
画面で探すなら、クエリのフィルタに正規表現を入れる
まず^ や $ を(?-i) を
この
| 探したい |
正規表現 | 当たった |
|---|---|---|
| 依頼文 |
(教えて|ください|したい|[。??]) |
1 |
| 疑問の |
(とは|違い|方法|やり方|どう|なぜ|いつ|どっち|何) |
6 |
| 文の |
(ます|です|ません|ました)$ |
1 |
| 20文字以上 |
^.{20,}$ |
4 |
| 空白で |
^\S+(\s+\S+){3,}$ |
5 |
^.{20,}$ は、
APIで取り出すときの条件
毎週
| 項目 | 内容 |
|---|---|
| rowLimit | 1〜25,000。 |
| startRow | 0から |
| dataState | 既定はall でhourly_all で |
| dimensions | country・device・page・query・searchAppearance・date・hour |
| 正規表現の |
dimensionFilterGroups の |
| 返る行 | すべての |
認証はcrypto で
// Search Console API から「検索語×ページ」を取り出して JSON に保存する(依存なし)
// 使い方: node gsc-fetch.mjs <siteUrl> <開始日> <終了日> <出力ファイル>
// 分ける項目は環境変数 DIMS(JSON の配列)、検索語の絞り込みは REGEX(RE2)で指定できる
import { createSign } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
const [siteUrl, startDate, endDate, out] = process.argv.slice(2);
const vars = readFileSync(process.env.SA_DEVVARS, 'utf8'); // 鍵は git 管理外のファイルから読む
const email = vars.match(/^GA_SA_CLIENT_EMAIL="?([^"\n]+)"?$/m)[1];
const key = vars.match(/^GA_SA_PRIVATE_KEY="?(.*?-----END PRIVATE KEY-----)/ms)[1].replace(/\\n/g, '\n');
async function token() {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const body = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({
iss: email, scope: 'https://www.googleapis.com/auth/webmasters.readonly',
aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600,
})}`;
const sig = createSign('RSA-SHA256').update(body).sign(key, 'base64url');
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${body}.${sig}` }),
});
if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
return (await res.json()).access_token;
}
const t = await token();
const url = `https://searchconsole.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`;
const rows = [];
for (let startRow = 0; ; startRow += 25000) {
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate, endDate, type: 'web', dataState: 'all',
dimensions: JSON.parse(process.env.DIMS ?? '["query","page"]'), rowLimit: 25000, startRow,
...(process.env.REGEX && { dimensionFilterGroups: [{ filters: [
{ dimension: 'query', operator: 'includingRegex', expression: process.env.REGEX },
] }] }),
}),
});
if (!res.ok) throw new Error(`query: ${res.status} ${await res.text()}`);
const page = (await res.json()).rows ?? [];
rows.push(...page);
if (page.length < 25000) break;
}
writeFileSync(out, JSON.stringify(rows, null, 1));
console.log(`${siteUrl} ${startDate}〜${endDate}: ${rows.length} 行`);
if (process.env.REGEX) for (const r of rows) console.log(` ${r.keys.join(' | ')}(表示 ${r.impressions})`);見えた検索語の表示は、合計の約半分だった
同じ
# 分け方を変えて3回取り出し、行数と合計を出す(sum.mjs は数行の集計)
DIMS='[]' node gsc-fetch.mjs sc-domain:yamayamabloglink.com 2026-09-05 2026-10-04 total.json
DIMS='["query"]' node gsc-fetch.mjs sc-domain:yamayamabloglink.com 2026-09-05 2026-10-04 query.json
DIMS='["page"]' node gsc-fetch.mjs sc-domain:yamayamabloglink.com 2026-09-05 2026-10-04 page.json
for n in total query page; do node sum.mjs $n.json; done
total.json: 1 行、表示 171、クリック 5
query.json: 57 行、表示 87、クリック 0
page.json: 59 行、表示 185、クリック 5| 分け方 | 行数 | 表示回数 | クリック数 |
|---|---|---|---|
| 分けない |
1 | 171 | 5 |
| 検索語ごと | 57 | 87 | 0 |
| ページごと | 59 | 185 | 5 |
ヘルプに
な
検索語を、依頼文・疑問・文・語の並びの4つに分ける
正規表現で
| 形 | 判定の目印 | 例 |
|---|---|---|
| 依頼文 | 教えて |
ai検索経由の |
| 疑問 | とは |
ga4 ai assistantとは |
| 文 | 〜ます・〜です・〜ません などで |
コンテンツの |
| 語の並び | 上の |
婚礼宴会 システム 管理 見積書作成 |
比べるIntl.Segmenter で
// 検索語を「形」で分け、意味のある語を取り出す(依存なし)
export const RULES = {
// AIに話しかけるような依頼の言い回し。句点・疑問符も依頼文の目印にする
request: /(教えて|ください|下さい|してほしい|して欲しい|したい|知りたい|書いて|作って|まとめて|[。??])/,
// 疑問の言い回し(依頼文に当たらなかったものだけを見る)
question: /(とは|って何|なに|何|どう|なぜ|なんで|いつ|どこ|どれ|どっち|違い|方法|やり方|仕方|できる|べき|か$)/,
// 文の終わり方(画面のメッセージや文章をそのまま貼った語)
sentence: /(ます|です|ません|ました|でした)$/,
};
// 依頼の動詞の語幹は、見出しと比べる語から外す
const STOP = new Set(['教え', '知り', '書い', '作っ']);
export const LONG_CHARS = 20; // 空白を除いた文字数がこれ以上なら「長い」
export const LONG_WORDS = 4; // 空白で区切った語の数がこれ以上なら「長い」
const seg = new Intl.Segmenter('ja', { granularity: 'word' });
const norm = (s) => s.normalize('NFKC').toLowerCase();
// 漢字・カタカナ・英数字を含む2文字以上の語だけを残す(助詞や「たい」「ます」は落ちる)
export function terms(q) {
const out = [...seg.segment(norm(q))]
.filter((s) => s.isWordLike)
.map((s) => s.segment)
.filter((w) => !STOP.has(w) && [...w].length >= 2 && /[\p{Script=Han}\p{Script=Katakana}a-z0-9]/u.test(w));
return [...new Set(out)];
}
export function shape(query) {
const q = norm(query).trim();
const words = q.split(/\s+/).filter(Boolean);
const chars = [...q.replace(/\s+/g, '')].length;
const kind = RULES.request.test(q) ? '依頼文'
: RULES.question.test(q) ? '疑問'
: RULES.sentence.test(q) ? '文'
: '語の並び';
const long = chars >= LONG_CHARS || words.length >= LONG_WORDS;
return { q, chars, words: words.length, kind, long, terms: terms(q) };
}
// 見出し(title と h2・h3)に入っていない語を返す
export function missingTerms(ts, headings) {
const hay = norm(headings.join(' '));
return ts.filter((t) => !hay.includes(t));
}ページのtitleとh2・h3に無い語を並べる
次に、dist/<パス>/index.html)から、
// gsc-fetch.mjs で保存した「検索語×ページ」を形で分け、ページの見出しにない語を並べる
// 使い方: node classify-queries.mjs <rows.json> <ビルド後の dist ディレクトリ>
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { shape, missingTerms } from './query-shape.mjs';
const [rowsFile, dist] = process.argv.slice(2);
const rows = JSON.parse(readFileSync(rowsFile, 'utf8'));
function headingsOf(pageUrl) {
const path = new URL(pageUrl).pathname;
const file = join(dist, path, 'index.html');
if (!existsSync(file)) return null;
const html = readFileSync(file, 'utf8');
const pick = (re) => [...html.matchAll(re)].map((m) => m[1].replace(/<[^>]+>/g, '').replace(/\u200b/g, '').trim()); // 改行調整のZWSPを外す
return [...pick(/<title>([\s\S]*?)<\/title>/g), ...pick(/<h[23][^>]*>([\s\S]*?)<\/h[23]>/g)];
}
const byKind = {};
const picked = [];
for (const r of rows) {
const [query, page] = r.keys;
const s = shape(query);
byKind[s.kind] ??= { 語: new Set(), 表示: 0 };
byKind[s.kind].語.add(s.q);
byKind[s.kind].表示 += r.impressions;
if (s.kind !== '語の並び' || s.long) {
const hs = headingsOf(page);
const u = new URL(page);
picked.push({ query, kind: s.kind, long: s.long, chars: s.chars,
page: (u.host.startsWith('www.') ? '' : '(wwwなし)') + u.pathname,
imp: r.impressions, pos: r.position.toFixed(1),
missing: hs ? missingTerms(s.terms, hs) : ['(ページが dist に無い)'] });
}
}
console.log('| 形 | 検索語の数 | 表示回数 |\n|---|---:|---:|');
for (const [k, v] of Object.entries(byKind)) console.log(`| ${k} | ${v.語.size} | ${v.表示} |`);
console.log('\n| 検索語 | 形 | 長い | 文字数 | ページ | 表示 | 順位 | 見出しにない語 |\n|---|---|---|---:|---|---:|---:|---|');
picked.sort((a, b) => b.chars - a.chars);
for (const p of picked) {
console.log(`| ${p.query} | ${p.kind} | ${p.long ? '○' : ''} | ${p.chars} | ${p.page} | ${p.imp} | ${p.pos} | ${p.missing.join('、') || '(すべてある)'} |`);
}2026年10月5日に、
| 形 | 検索語の数 | 表示回数 |
|---|---|---|
| 語の並び | 48 | 80 |
| 依頼文 | 1 | 1 |
| 疑問 | 6 | 7 |
| 文 | 1 | 1 |
| 検索語 | 形 | 長い | 文字数 | ページ | 表示 | 順位 | 見出しに |
|---|---|---|---|---|---|---|---|
| ai検索経由の |
依頼文 | ○ | 40 | /ga4-ai-search-traffic-channel-group/ | 1 | 9.0 | 経由、流入 |
| コンテンツの |
文 | ○ | 30 | /search-console-discovered-crawled-not-indexed/ | 1 | 83.0 | コンテンツ |
| copy anything to clipboard | 語の並び | ○ | 23 | /one-click-copy/ | 1 | 9.0 | (すべてある) |
| ga4 ai assistantとは | 疑問 | 16 | /ga4-ai-search-traffic-channel-group/ | 1 | 4.0 | (すべてある) | |
| 婚礼宴会 システム 管理 見積書作成 | 語の並び | ○ | 15 | /wedding-estimate-gap-system-design/ | 1 | 18.0 | 宴会、作成 |
| cms wordpress 違い | 疑問 | 14 | /headless-cms-vs-wordpress-smb-site/ | 1 | 83.0 | (すべてある) | |
| 同棲 タイミング 社会人 半年 | 語の並び | ○ | 12 | /syakaizin-dousei5/ | 4 | 8.5 | (すべてある) |
| 同棲 タイミング 社会人 半年 | 語の並び | ○ | 12 | (wwwなし) |
1 | 17.0 | (すべてある) |
| 24 時間 ジム 監視 カメラ | 語の並び | ○ | 11 | /unmanned-gym-entry-qr-smart-lock/ | 2 | 22.5 | 監視 |
| 同棲 何ヶ月前から |
疑問 | 10 | (wwwなし) |
1 | 11.0 | ヶ月 | |
| 同棲 転職 どっち が |
疑問 | ○ | 9 | /dousei-tensyoku/ | 2 | 6.5 | (すべてある) |
| 同棲 いつから |
疑問 | 8 | /shinsotsu-dousei/ | 1 | 9.0 | (すべてある) | |
| 同棲 いつから | 疑問 | 6 | /syakaizin-dousei5/ | 1 | 18.0 | (すべてある) |
結果から、直すところと直さないところを分ける
表示が
- 見出しに
無くても、 依頼文の答えは 本文に ある。 「正規表現の 設定例を 教えて」は、 GA4の 記事の 「手順2:参照元を 判定する 正規表現を 書き、 探索で 試す」に 答えが あります。 見出しに 無かった 語は 「経由」 「流入」で、 書き直す 理由には なりません。 同じ形の 依頼文が 続くようなら、 よく ある 質問に 1問足すかを 考えます - 答えていない
状態が ある。 「コンテンツの ない 状態で ページが インデックスに 登録されています」は、 Search Consoleの ページの インデックス登録レポートに 出てくる 状態の 名前です (ヘルプで 確認)。 表示されたのは 「検出 - インデックス未登録」 「クロール済み - インデックス未登録」の 記事でしたが、 この 状態は 扱っていません。 表示が また 出たら、 節を 足すか 別の 記事に するかを 決めます - ほかの
記事の 題に なる。 「婚礼宴会 システム 管理 見積書作成」は、 見積もりの 上がり幅を 減らす記事で 表示されていました。 その 記事は 品目と 変更履歴の 持ち方が 中心で、 「宴会」と、 見積書の 金額を どう 計算するかは 扱っていません。 そこで、 別の 記事と して 「婚礼・宴会の 見積書の 」を金額計算を、 見積もりシステムに 実装する 書きました
株式会社bundlyzeでは、
長い検索語が、AIの機能から来たかどうかは分からない
文章で
長い
テスト
形のnode:test)で
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shape, terms, missingTerms } from './query-shape.mjs';
test('句点と「教えて」がある語は依頼文', () => {
assert.equal(shape('AI検索経由の流入をGA4のチャネルグループに分けたい。正規表現の設定例を教えて').kind, '依頼文');
});
test('「とは」「違い」は疑問', () => {
assert.equal(shape('ga4 ai assistantとは').kind, '疑問');
assert.equal(shape('cms wordpress 違い').kind, '疑問');
});
test('「〜ています」で終わる語は文', () => {
assert.equal(shape('コンテンツのない状態でページがインデックスに登録されています').kind, '文');
});
test('空白で区切った4語は長い。全角英数字と半角カナはそろえる', () => {
const s = shape('24 時間 ジム 監視');
assert.equal(s.long, true);
assert.equal(s.q, '24 時間 ジム 監視');
assert.equal(shape('プログラミング 仕事ない').q, 'プログラミング 仕事ない');
});
test('取り出す語から助詞と依頼の語幹を外す', () => {
assert.deepEqual(terms('正規表現の設定例を教えて'), ['正規', '表現', '設定']);
});
test('見出しにない語だけを返す', () => {
assert.deepEqual(missingTerms(['婚礼', '宴会', '見積'], ['婚礼見積もりの「上がり幅」を減らす']), ['宴会']);
});node --test query-shape.test.mjs の
ok 1 - 句点と「教えて」がある語は依頼文
ok 2 - 「とは」「違い」は疑問
ok 3 - 「〜ています」で終わる語は文
ok 4 - 空白で区切った4語は長い。全角英数字と半角カナはそろえる
ok 5 - 取り出す語から助詞と依頼の語幹を外す
ok 6 - 見出しにない語だけを返す
# tests 6
# pass 6
# fail 0動作確認した環境
確認日は
- macOS 26.6.2、
Node.js 22.23.2 (組み込みの fetch・crypto・Intl.Segmenter・node:test) - Search Console API
(searchAnalytics.query)、 この ブログの ドメインプロパティ ( sc-domain:yamayamabloglink.com)。サービスアカウントに プロパティの 権限を 付けて 読み取りのみ - 見出しの
突き合わせは、 同じ 日に npm run buildしたdist/を使用
確かめていない
- 正規表現の
表の 件数は、 APIで 流した 結果です。 同じ 正規表現を 画面の フィルタに 入れて 件数が 一致するかは 試していません - 生成 AI パフォーマンス レポートを、
この ブログの プロパティで 開いた ときに 表示が あるか (画面は 見ていません) - 生成 AI パフォーマンス レポートの
数字を APIで 取れるか - 判定の
目印 (教えて・とは など)の 取り こぼし。 この ブログに 来た 57語でしか 確かめていません - 25,000行を
超える サイトでの startRowの繰り返し (この ブログは 63行で、 1回で 取り終わりました)
参照した公式ドキュメント(確認日:2026年10月5日)
Search Consoleの
- rowLimit
(1〜25,000、 既定1,000)、 startRow、 dataState (final・all・hourly_all)、 dimensions、 includingRegex・excludingRegex (RE2)、 上位の 行だけを 返す こと、 並び順 → Google 「Search Analytics: query」 (最終更新 2026-08-11) - 正規表現の
フィルタ (カスタム〈正規表現〉、 RE2、 部分 一致、 大文字と 小文字を 区別しない、 (?-i))→ Search Console ヘルプ「高度な フィルタリングと 」比較 - 匿名化された
クエリが 表から 外れ、 検索語で 絞り込まない 限りグラフの 合計に 入る こと、 内部の 制限で 表に 出ない 検索語が ある こと → Search Console ヘルプ「ディメンションと データグループ 」「トラブルシューティングとデータの 」不一致 - プロパティごとの
集計と ページごとの 集計の 違い → Search Console ヘルプ「クリック数と インプレッション数の 」カウント方法 - AIに
よる 概要・AIモードに 表示された 分も、 検索タイプ「ウェブ」の パフォーマンスレポートに 含まれる こと → Google 検索セントラル 「AI 機能と ウェブサイト 」 - 生成 AI パフォーマンス レポート
(2026年8月31日に すべての サイトに 公開、 AIに よる 概要と AIモードの 表示、 ディメンションは ページ・国・日付・ デバイス) → Search Console ヘルプ「生成 AI パフォーマンス レポート」 - 「コンテンツの
ない 状態で ページが インデックスに 登録されています」が ページの インデックス登録レポートの 状態である こと → Search Console ヘルプ「ページの インデックス登録レポート 」
よくある質問
Search Consoleの画面で、質問の形の検索語だけを見るにはどうしますか?
検索パフォーマンスの
Search Console APIでも正規表現は使えますか?
使えます。
長い検索語は、AIモードやAIによる概要から来たものですか?
Search Consoleの
検索語の一覧に出てくる表示回数を足すと、合計より少なくなるのはなぜですか?
利用者の