IT技術ブログ

長い問いかけの検索語を、Search Console APIで抜き出して記事の見出しと突き合わせる。正規表現(RE2)での絞り込み、依頼文・疑問・文・語の並びに分けるNodeのスクリプト、匿名化で見えない分の数え方

AIに話しかけるような長い検索語を、Search Console APIと正規表現で抜き出し、記事の見出しにない語を並べるNodeのスクリプトです。依頼文・疑問・文の分け方、匿名化で見えない表示の割合、このブログで流した結果まで。

この記事の結論:「ai検索経由の流入をga4のチャネルグループに分けたい。正規表現の設定例を教えて」のような、AIに話しかけるような長い検索語は、Search Consoleの正規表現(RE2)のフィルタで抜き出せます。この記事では、Search Console APIから「検索語×ページ」を取り出すスクリプトと、検索語を「依頼文・疑問・文・語の並び」に分け、そのページのtitleとh2・h3に無い語を並べるスクリプトをNodeで書き、2026年10月5日にこのブログのデータで流しました。見えた検索語の表示は合計の約半分(171回のうち87回)で、長い検索語は数件でした。1回だけの検索語で記事を書き直すのではなく、同じ形の語が続いたページから見出しやよくある質問に戻すための、見張りの道具として使います。

この記事の内容のご相談は株式会社bundlyzeへbundlyzeのSEO・LLMO対策について見てみる

このブログでは、2026年10月4日のSearch Consoleに、冒頭の依頼文そのものの検索語が、GA4のAI検索のチャネル分けを書いた記事(AI検索からの訪問をGA4で正しく数える実装)で1回表示されていました。語を並べる検索ではなく、文章で頼む検索です。こうした語は数が少なく、画面で眺めていると見落とすので、決まった形で抜き出す仕組みにしました。Search Consoleを使い始めるところは、会社のコラム「サーチコンソールの使い方。公開した日にやること」に書いています。この記事はその先の、検索語を記事の直しに使うところだけを扱います。

画面で探すなら、クエリのフィルタに正規表現を入れる

まずSearch Consoleの画面だけで探す方法です。検索パフォーマンスのレポートで、「クエリ」のフィルタに「カスタム(正規表現)」を選ぶと、正規表現に一致する(または一致しない)検索語だけを表に出せます。ヘルプには、構文はRE2、何も付けなければ部分一致(^ や $ を使わない限り文字列のどこに当たってもよい)、大文字と小文字は区別しない、区別したいときは先頭に (?-i) を付ける、と書かれています。

このブログで使った正規表現と、同じ正規表現をAPIで流したときの件数(2026年9月5日〜10月4日、ドメインプロパティ)は次のとおりです。

探したいもの 正規表現 当たった検索語
依頼文(AIに頼むような言い回し・句点・疑問符) (教えて|ください|したい|[。??]) 1
疑問の言い回し (とは|違い|方法|やり方|どう|なぜ|いつ|どっち|何) 6
文の終わり方(メッセージなどの貼り付け) (ます|です|ません|ました)$ 1
20文字以上(空白も1文字) ^.{20,}$ 4
空白で区切った4語以上 ^\S+(\s+\S+){3,}$ 5

^.{20,}$ は、RE2では1文字を1つとして数えるので、日本語でもバイト数ではなく文字数で当たりました。「wordpress microcms 移行」(空白を含めて22文字)も当たっています。

APIで取り出すときの条件

毎週同じ形で取り出すなら、Search Console APIの searchAnalytics.query を使います。リファレンスで確かめた、この記事のスクリプトに関係する条件は次のとおりです。

項目 内容
rowLimit 1〜25,000。既定は1,000
startRow 0から始まる行の位置。25,000行を超えるときはずらして取り直す
dataState 既定は確定したデータのみ(final)。all で新しいデータも含める。hourly_all で1時間ごと
dimensions country・device・page・query・searchAppearance・date・hour
正規表現の絞り込み dimensionFilterGroups の operator に includingRegex・excludingRegex(どちらもRE2)
返る行 すべての行ではなく上位の行。日付で分けたとき以外はクリック数の多い順

認証はサービスアカウントで行い、Search Consoleのプロパティにそのアカウントを追加しておきます。鍵はgitで管理しないファイルに置き、スクリプトは環境変数でその場所だけを受け取ります。依存するライブラリを入れずに、Nodeの crypto でJWTに署名してアクセストークンを取っています。

js
// 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回取り出しました。分けずに取った合計と、検索語で分けた表の合計が合わないのは、検索語の一部が表から外されるためです。

bash
# 分け方を変えて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

ヘルプによると、利用者のプライバシーを守るため一部の検索語(匿名化されたクエリ)は表から外され、検索語で絞り込まない限りグラフの合計には入ります。また内部の制限で、匿名化されたもの以外にも表に出ない検索語があります。このブログでは、検索語が見えたのは表示の51%(171回のうち87回)だけで、クリック5回はすべて検索語が見えない側でした。ページごとの合計が171より多いのは、検索語やプロパティで分けたときはプロパティごと、ページで分けたときはページごとに数える決まりのためです(同じ検索結果に2つのページが出れば、ページごとでは2回になります)。

なお、ドメインプロパティで日付ごとに取ると、データがあったのは2026年9月30日からの5日分でした。このブログは2026年10月2日に新しい作りに移したばかりで、件数が少ないのはそのためです。

検索語を、依頼文・疑問・文・語の並びの4つに分ける

正規表現で抜き出すだけでは、どの記事のどこを直すかまでは分かりません。そこで、検索語を形で4つに分け、そのページの見出しと突き合わせるスクリプトを書きました。形の判定は、上から順に当てはめます。

形 判定の目印 例(このブログに来た語)
依頼文 教えて・ください・したい・知りたい、句点・疑問符 ai検索経由の流入をga4のチャネルグループに分けたい。正規表現の設定例を教えて
疑問 とは・違い・方法・やり方・どう・なぜ・いつ・どっち など ga4 ai assistantとは
文 〜ます・〜です・〜ません などで終わる コンテンツのない状態でページがインデックスに登録されています
語の並び 上のどれにも当たらない 婚礼宴会 システム 管理 見積書作成

比べる前に、検索語はNFKCで正規化し、小文字にそろえます。半角カナの「プログラミング 仕事ない」と全角の「プログラミング 仕事ない」が、このブログでは別の検索語として返ってきましたが、正規化すると1つになります。見出しと比べる語は、Nodeに組み込みの Intl.Segmenter で日本語を語に区切り、漢字・カタカナ・英数字を含む2文字以上の語だけを残します。助詞や「たい」「ます」はここで落ちます。

js
// 検索語を「形」で分け、意味のある語を取り出す(依存なし)
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に無い語を並べる

次に、検索語が表示されたページのビルド後のHTML(dist/<パス>/index.html)から、titleとh2・h3を取り出し、検索語の語のうち見出しに無いものを並べます。このブログは本文の改行位置を整えるためにゼロ幅スペース(U+200B)を入れているので、比べる前に外しています。

js
// 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日に、9月5日〜10月4日の「検索語×ページ」63行で流した結果です。長い語の並びと、依頼文・疑問・文だけを表に出しています。

形 検索語の数 表示回数
語の並び 48 80
依頼文 1 1
疑問 6 7
文 1 1
検索語 形 長い 文字数 ページ 表示 順位 見出しにない語
ai検索経由の流入をga4のチャネルグループに分けたい。正規表現の設定例を教えて 依頼文 ○ 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なし)/syakaizin-dousei5/ 1 17.0 (すべてある)
24 時間 ジム 監視 カメラ 語の並び ○ 11 /unmanned-gym-entry-qr-smart-lock/ 2 22.5 監視
同棲 何ヶ月前から 探す 疑問 10 (wwwなし)/shinsotsu-dousei/ 1 11.0 ヶ月
同棲 転職 どっち が先 疑問 ○ 9 /dousei-tensyoku/ 2 6.5 (すべてある)
同棲 いつから 新卒 疑問 8 /shinsotsu-dousei/ 1 9.0 (すべてある)
同棲 いつから 疑問 6 /syakaizin-dousei5/ 1 18.0 (すべてある)

結果から、直すところと直さないところを分ける

表示が1回の検索語で記事を書き直すと、たまたまの1回に振り回されます。この表は、次の3つに分けて読むことにしました。

  • 見出しに無くても、答えは本文にある。 依頼文の「正規表現の設定例を教えて」は、GA4の記事の「手順2:参照元を判定する正規表現を書き、探索で試す」に答えがあります。見出しに無かった語は「経由」「流入」で、書き直す理由にはなりません。同じ形の依頼文が続くようなら、よくある質問に1問足すかを考えます
  • 答えていない状態がある。 「コンテンツのない状態でページがインデックスに登録されています」は、Search Consoleのページのインデックス登録レポートに出てくる状態の名前です(ヘルプで確認)。表示されたのは「検出 - インデックス未登録」「クロール済み - インデックス未登録」の記事でしたが、この状態は扱っていません。表示がまた出たら、節を足すか別の記事にするかを決めます
  • ほかの記事の題になる。 「婚礼宴会 システム 管理 見積書作成」は、見積もりの上がり幅を減らす記事で表示されていました。その記事は品目と変更履歴の持ち方が中心で、「宴会」と、見積書の金額をどう計算するかは扱っていません。そこで、別の記事として「婚礼・宴会の見積書の金額計算を、見積もりシステムに実装する」を書きました

株式会社bundlyzeでは、このようにSearch Consoleの表示とAIからの訪問・問い合わせを並べて見ながら、既存のページの直しと、次に作るページを毎月決めるところまで、SEO・LLMOの取り組みとして一緒に進めています。

長い検索語が、AIの機能から来たかどうかは分からない

文章で頼む検索語を見ると、AIモードやAIによる概要から来たのではないかと考えたくなりますが、この記事のデータからは分かりません。Googleの説明では、AIによる概要やAIモードに表示されたサイトの分も、Search Consoleの検索タイプ「ウェブ」のパフォーマンスレポートに含まれます。2026年8月31日にすべてのサイトに公開された「生成 AI パフォーマンス レポート」は、生成AIの機能での表示を見られますが、ヘルプにあるディメンションはページ・国・日付・デバイスで、検索語では分けられません。

長い検索語が出たページが、生成AIパフォーマンスレポートでも表示されていれば手がかりにはなりますが、同じ検索から来たとは言えません。この記事のスクリプトでは、検索語の形だけを見て、どこから来たかは判断していません。

テスト

形の判定と語の取り出しは、Nodeに組み込みのテストランナー(node:test)で固定しました。

js
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

動作確認した環境

確認日は2026年10月5日です。

  • 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のヘルプは画面の名前や項目が変わることがあるので、使う前に今の内容を読み直してください。

よくある質問

Search Consoleの画面で、質問の形の検索語だけを見るにはどうしますか?

検索パフォーマンスのレポートで「クエリ」のフィルタを開き、「カスタム(正規表現)」を選んで、(とは|違い|方法|やり方|どう|なぜ|いつ) のような正規表現を入れます。ヘルプによると、構文はRE2で、何も付けなければ部分一致、大文字と小文字は区別しません。区別したいときは先頭に (?-i) を付けます。

Search Console APIでも正規表現は使えますか?

使えます。searchAnalytics.query の dimensionFilterGroups で、operator に includingRegex(一致するもの)か excludingRegex(一致しないもの)を指定し、RE2の正規表現を渡します。このブログで試したところ、APIでも何も付けなければ大文字と小文字を区別せず、(?-i) を付けると区別しました。

長い検索語は、AIモードやAIによる概要から来たものですか?

Search Consoleの検索パフォーマンスのデータからは分かりません。Googleの説明では、AIによる概要やAIモードに表示された分も、検索タイプ「ウェブ」のパフォーマンスレポートに含まれます。別にある生成AIパフォーマンスレポートのディメンションはページ・国・日付・デバイスで、検索語では分けられません。

検索語の一覧に出てくる表示回数を足すと、合計より少なくなるのはなぜですか?

利用者のプライバシーを守るため、一部の検索語(匿名化されたクエリ)が表から外されるためです。外された分も、検索語で絞り込まない限りグラフの合計には入ります。このブログの2026年9月5日〜10月4日のデータでは、合計の表示171回のうち、検索語が見えたのは87回でした。

この記事の書き方と確認の方針