IT技術ブログ

UTMパラメータの付け方を点検するスクリプト。ビルド後のHTMLの全リンクから utm_* を集め、命名ルール(小文字、決めた source/medium の一覧、社内リンクにUTMを付けない、utm_campaign の必須など)に反するものを出す

ビルド後のHTMLの全リンクからUTMパラメータを集め、大文字、一覧に無いsourceとmedium、utm_campaignの抜け、自サイトへのUTMを見つけるNodeのスクリプトです。このブログで流した結果も載せます。

この記事の結論:UTMパラメータの付け方の乱れは、ビルド後のHTMLの全リンクからutm_*を集めるスクリプトで、公開の前に見つけられます。点検するのは、①値とパラメータ名に大文字が無いか、②utm_sourceとutm_mediumが決めた一覧の中にあるか、③utm_source・utm_medium・utm_campaignの3つがそろっているか、④自分のサイトへのリンクにUTMが付いていないか、⑤綴りを間違えたパラメータ名や#の後ろに書いたUTMが無いか、の5つです。このブログ(270ページ、リンク17,974本)で流したところ、UTM付きのリンクは319本で、エラーは0件、注意は2件でした。もう一つ分かったのは、会社サイトへのリンクのうち1,083本にはUTMが付いておらず、会社サイトのGA4では、このブログからの訪問が2つの参照元に分かれて数えられるはずだということです。

この記事では、そのスクリプトの全文と、2026年10月4日にこのブログで流した結果、わざと崩したリンクを並べたページで見落とさないかを確かめた結果を載せます。会社サイトのGA4のレポートを開いて、実際の数え方を確かめることはしていません。

UTMの乱れは、公開の前にHTMLから見つける

UTMの付け方の乱れは、GA4のレポートで気づいたときには、もう数字が分かれて記録されています。ビルド後のHTMLを読めば、公開の前に止められます。

GA4は、届いた値をそのまま記録します。utm_source=Instagramとutm_source=instagramは別の参照元として残り、あとから1つにまとめるには、レポートの側で条件を作るしかありません。リンクを書く人が増えるほど、こうした小さな揺れは増えていきます。

UTMの付いたリンクは、記事、テンプレートの部品、設定ファイルなど、あちこちから生まれます。このブログでは、記事末の案内のリンクに、設定ファイルの関数でUTMを付けています。

src/config/site.ts(抜粋)ts
export const ctaUtm = (url: string, kind: string, slug: string) => {
  const u = new URL(url);
  u.searchParams.set('utm_source', 'yamayamabloglink');
  u.searchParams.set('utm_medium', 'referral');
  u.searchParams.set('utm_campaign', 'blog_cta');
  u.searchParams.set('utm_content', `${kind}_${slug}`.slice(0, 100));
  return u.toString();
};

作る場所が1か所なら揺れは起きにくいのですが、記事の本文に手で書いたリンクや、別の部品から足したリンクは、この関数を通りません。そこで、作る場所ではなく、出来上がったHTMLの側でまとめて確かめます。

命名ルールと、その根拠

ルールは、GA4のヘルプに書かれていることと、自社で決めることに分けます。前者は守らないと数字が分かれたり欠けたりし、後者は見やすさのための約束です。

ルール 区分 根拠
値とパラメータ名は小文字 エラー ヘルプに、値は大文字と小文字が区別されると書かれている。googleとGoogleは別の値になる
utm_source・utm_medium・utm_campaignをそろえる エラー ヘルプで、この3つは使う必要があるとされている。付けないものは「(not set)」になる
名前はヘルプにある9つだけ エラー utm_cotentのような綴り間違いは、GA4に値として届かない
#の後ろにUTMを書かない エラー #より後ろはURLの問い合わせ文字列にならない
自分のサイトへのリンクにUTMを付けない エラー 自社の決まり(次の節で説明)
sourceとmediumは決めた一覧から選ぶ エラー 自社の決まり。一覧に無い値は書き間違いか、相談なく増えた値
値に使う文字は英小文字・数字・_・-・.だけ 注意 自社の決まり。空白や日本語は、レポートやURLで読みにくくなる
mediumだけで既定のチャネルが決まるか 注意 既定のチャネルグループの条件。決まらないものは、参照元しだいで「Unassigned」になる
同じutm_contentを別の行き先に使わない 注意 自社の決まり。どのリンクが押されたかを1つに決められなくなる

utm_mediumの値は、GA4の既定のチャネルグループの振り分けに直接効きます。ヘルプの条件の主なものは次のとおりです。条件は大文字と小文字を区別しません。

mediumの値 振り分けられるチャネル
referral、app、link Referral
email、e-mail、e_mail、e mail Email(参照元がこれらの値でも同じ)
social、social-network、social-media、sm、social network、social media Organic Social(参照元がSNSの一覧に当たっても同じ)
display、banner、expandable、interstitial、cpm Display
organic Organic Search
正規表現^(.*cp.*|ppc|retargeting|paid.*)$に当たるもの 参照元に応じてPaid Searchなどに。どれにも当たらなければPaid Other

たとえばutm_medium=snsは、この表のどれにも当たりません。参照元がfacebook.comのようにSNSの一覧に入っていればOrganic Socialになりますが、utm_source=lineのように一覧に無い値と組むと、Unassignedに落ちるおそれがあります。

自分のサイトへのリンクにUTMを付けない理由

UTMは、外からどう来たかを表すための目印だからです。サイトの中の移動に付けると、外から来た経路と、サイトの中の場所が、同じ欄に混ざります。

GA4のヘルプでは、セッションの始まりに記録される情報に、UTMパラメータや参照元のURLが含まれ、それがセッションの経路を決める材料になると説明されています。サイトの中のリンクにUTMを付けると、外から来た経路を記録するはずの欄に、「記事の下のバナー」のようなサイトの中の場所を表す値が入り込みます。ヘルプには、サイトの中のリンクについての決まりは見当たらなかったので、これは自社の決まりとしてエラーにしています。

サイトの中のどのリンクが押されたかを知りたいときは、UTMではなく、クリックをイベントとして送って数えます。経路の欄は外からの経路だけにしておくほうが、あとでレポートを読み違えません。

一方、自社の別のサイトへのリンクは、外へのリンクと同じ扱いです。このブログから会社サイトへのリンクは、会社サイトのGA4から見れば外から来た訪問なので、UTMを付けても構いません。スクリプトでは、自社の別サイトをownHostsとして分け、UTMを付けていないリンクの数を数えるだけにしています。

点検のスクリプト

スクリプトは、Node.jsとHTMLを読むためのcheerioだけで動きます。node utm-audit.mjs <distのフォルダ> <サイトのURL>で動かし、エラーが1件でもあれば終了コード1で終わります。ルールは先頭のRULESにまとめているので、自社の一覧に書き換えて使います。

utm-audit.mjsjs
// utm-audit.mjs — ビルド後の dist のHTMLから、リンクに付いた utm_* を集めて命名ルールに照らす
// 使い方: node utm-audit.mjs <distのフォルダ> <サイトのURL>
//   例:   node utm-audit.mjs ./dist https://www.example.com
// 依存: cheerio(npm i -D cheerio)
import fs from 'node:fs';
import path from 'node:path';
import * as cheerio from 'cheerio';

const DIST = path.resolve(process.argv[2] ?? 'dist');
const SITE = new URL(process.argv[3] ?? 'https://www.example.com');

// ---- 命名ルール(自社で決めるもの。ここを書き換えて使う) ----
const RULES = {
  // このサイト自身のホスト。ここへのリンクにUTMを付けたらエラー
  internalHosts: [SITE.host, SITE.host.replace(/^www\./, '')],
  // 自社の別サイト。UTMを付けるかどうかは自由だが、付けるならルールに従う。付いていない数を報告する
  ownHosts: ['www.bundlyze.co.jp', 'bundlyze.co.jp'],
  // 付けてよい utm_* の名前(GA4のヘルプにある9つ)。これ以外は綴り間違いとして出す
  knownKeys: ['utm_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_source_platform', 'utm_term', 'utm_content', 'utm_creative_format', 'utm_marketing_tactic'],
  // 1つでも utm_* を付けたら、必ず付ける
  required: ['utm_source', 'utm_medium', 'utm_campaign'],
  // 決めた値の一覧
  sources: ['yamayamabloglink', 'newsletter', 'line', 'instagram', 'google', 'meta'],
  mediums: ['referral', 'email', 'social', 'cpc', 'display'],
  // 値に使ってよい文字(小文字の英数字と _ - .)。content は記事のスラッグが入るので長さも見る
  valuePattern: /^[a-z0-9_.-]+$/,
  maxLength: 100,
};

// GA4の既定のチャネルグループで、メディアだけで決まるものの早見(ヘルプの条件の一部。大文字小文字は区別しない)
// 参照元の一覧(検索サイト・SNS など)に当たるかどうかで決まるものは、ここでは判定しない
const channelByMedium = (m) => {
  if (/^(referral|app|link)$/.test(m)) return 'Referral';
  if (/^(email|e-mail|e_mail|e mail)$/.test(m)) return 'Email';
  if (/^(display|banner|expandable|interstitial|cpm)$/.test(m)) return 'Display';
  if (/^(social|social-network|social-media|sm|social network|social media)$/.test(m)) return 'Organic Social';
  if (/^(.*cp.*|ppc|retargeting|paid.*)$/.test(m)) return '有料系(参照元しだい。どれにも当たらなければ Paid Other)';
  if (/^(organic)$/.test(m)) return 'Organic Search';
  return null; // メディアだけでは決まらない(参照元の一覧に当たらなければ Unassigned)
};

const issues = []; // { level, kind, page, href, detail }
const add = (level, kind, page, href, detail = '') => issues.push({ level, kind, page, href, detail });

function walk(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
    const p = path.join(dir, e.name);
    return e.isDirectory() ? walk(p) : p.endsWith('.html') ? [p] : [];
  });
}
const fileToPath = (file) => '/' + path.relative(DIST, file).split(path.sep).join('/').replace(/(^|\/)index\.html$/, '$1');

let pageCount = 0;
let linkCount = 0;
const utmLinks = []; // { page, href, params }
const ownWithoutUtm = new Map(); // 自社の別サイトへの、UTMの無いリンク: 行き先 → 件数
const combos = new Map(); // source / medium / campaign の組 → 件数

for (const file of walk(DIST)) {
  const html = fs.readFileSync(file, 'utf8');
  if (!/<html[\s>]/i.test(html.slice(0, 2000))) continue; // RSS などHTMLでないものは飛ばす
  pageCount++;
  const page = fileToPath(file);
  const $ = cheerio.load(html);

  $('a[href], area[href]').each((_, el) => {
    const raw = $(el).attr('href').trim();
    let url;
    try { url = new URL(raw, SITE); } catch { return; }
    if (!/^https?:$/.test(url.protocol)) return;
    linkCount++;

    // # の後ろに utm_ を書いても、URLの問い合わせ文字列にならない
    if (/[#&?]utm_/i.test(url.hash)) add('error', '#の後ろにUTMがある', page, raw);

    // 名前は大文字小文字を問わず拾う(UTM_Source のような書き間違いも見つけるため)
    const params = [...url.searchParams].filter(([k]) => /^utm_/i.test(k));
    if (params.length === 0) {
      if (RULES.ownHosts.includes(url.host)) {
        const key = url.host + url.pathname;
        ownWithoutUtm.set(key, (ownWithoutUtm.get(key) ?? 0) + 1);
      }
      return;
    }
    utmLinks.push({ page, href: raw, params });

    if (RULES.internalHosts.includes(url.host)) {
      add('error', '自サイトへのリンクにUTMがある', page, raw);
    }

    const seen = new Map();
    for (const [k, v] of params) {
      if (seen.has(k)) add('error', '同じパラメータが2回ある', page, raw, k);
      seen.set(k, v);
      if (k !== k.toLowerCase()) add('error', 'パラメータ名に大文字がある', page, raw, k);
      if (!RULES.knownKeys.includes(k.toLowerCase())) add('error', '知らないパラメータ名(綴り間違い?)', page, raw, k);
      if (v === '') { add('error', '値が空', page, raw, k); continue; }
      if (v !== v.toLowerCase()) add('error', '値に大文字がある', page, raw, `${k}=${v}`);
      else if (!RULES.valuePattern.test(v)) add('warn', '値に決めた文字以外がある', page, raw, `${k}=${v}`);
      if ([...v].length > RULES.maxLength) add('warn', '値が長い', page, raw, `${k}(${[...v].length}字)`);
    }

    for (const k of RULES.required) if (!seen.has(k)) add('error', `${k} が無い`, page, raw);

    const source = (seen.get('utm_source') ?? '').toLowerCase();
    const medium = (seen.get('utm_medium') ?? '').toLowerCase();
    const campaign = (seen.get('utm_campaign') ?? '').toLowerCase();
    if (source && !RULES.sources.includes(source)) add('error', '一覧に無い utm_source', page, raw, source);
    if (medium && !RULES.mediums.includes(medium)) add('error', '一覧に無い utm_medium', page, raw, medium);
    if (medium && !channelByMedium(medium)) add('warn', 'メディアだけでは既定のチャネルが決まらない', page, raw, medium);
    const combo = `${source} / ${medium} / ${campaign} → ${medium ? channelByMedium(medium) ?? '?' : '?'}`;
    combos.set(combo, (combos.get(combo) ?? 0) + 1);
  });
}

// ---- 同じ utm_content が、違う行き先に使われていないか ----
const contentDest = new Map();
for (const { href, params } of utmLinks) {
  const c = params.find(([k]) => k.toLowerCase() === 'utm_content')?.[1];
  if (!c) continue;
  const u = new URL(href, SITE);
  const dest = u.host + u.pathname;
  if (!contentDest.has(c)) contentDest.set(c, new Set());
  contentDest.get(c).add(dest);
}
for (const [c, dests] of contentDest) {
  if (dests.size > 1) add('warn', '同じ utm_content が別の行き先にある', '-', [...dests].join(' , '), c);
}

// ---- 結果を出す ----
const errors = issues.filter((i) => i.level === 'error');
const warns = issues.filter((i) => i.level === 'warn');
console.log(`点検したページ: ${pageCount} / http(s)のリンク: ${linkCount} / UTM付きのリンク: ${utmLinks.length}`);
console.log('source / medium / campaign の組(→ メディアから見た既定のチャネル):');
for (const [k, n] of [...combos].sort((a, b) => b[1] - a[1])) console.log(`  ${k}: ${n}件`);
const ownTotal = [...ownWithoutUtm.values()].reduce((s, n) => s + n, 0);
console.log(`自社の別サイトへの、UTMの無いリンク: ${ownTotal}件(行き先 ${ownWithoutUtm.size}種類)`);
console.log(`エラー: ${errors.length} / 注意: ${warns.length}`);

const groups = new Map();
for (const i of issues) {
  const key = `[${i.level === 'error' ? 'エラー' : '注意'}] ${i.kind}`;
  if (!groups.has(key)) groups.set(key, []);
  groups.get(key).push(i);
}
for (const [key, list] of [...groups].sort()) {
  console.log(`\n${key}: ${list.length}件`);
  for (const i of list.slice(0, 5)) console.log(`  ${i.page}  ${i.href}${i.detail ? `  (${i.detail})` : ''}`);
  if (list.length > 5) console.log(`  ……ほか${list.length - 5}件`);
}
if (process.argv.includes('--own')) {
  console.log('\n自社の別サイトへの、UTMの無いリンク(行き先ごと):');
  for (const [k, n] of [...ownWithoutUtm].sort((a, b) => b[1] - a[1])) console.log(`  ${k}: ${n}件`);
}
process.exitCode = errors.length > 0 ? 1 : 0;

書くときに気をつけた点は、次の4つです。

  • パラメータ名は大文字小文字を問わず拾う。 UTM_Sourceのような書き間違いは、小文字のutm_だけを探すと見落とすため、拾ったうえでエラーにしています。
  • 値の%の変換は、URLの読み取りに任せる。 URLのsearchParamsは、%20や+を空白に、%E3...を日本語に戻してから値を返すので、見た目の値で判定できます。
  • mediumからのチャネルは早見にとどめる。 既定のチャネルグループには、参照元が検索サイトやSNSの一覧に当たるかどうかで決まる条件もあります。一覧はGoogleが配っている表で、スクリプトには持たせていないため、「mediumだけでは決まらない」を注意として出し、最後は人が見ます。
  • 自社の別サイトへのUTMの無いリンクは、エラーにせず数える。 付けるかどうかは、そのリンクを相手側でどう数えたいかで決まるからです(結果の節で扱います)。

このブログで流した結果:エラー0件、注意2件

2026年10月4日23時ごろにビルドしたdistのコピーに流した結果です。UTMの付いたリンク319本は、すべて同じ source・medium・campaign の組で、エラーはありませんでした。

$ node utm-audit.mjs ./dist https://www.yamayamabloglink.com
点検したページ: 270 / http(s)のリンク: 17974 / UTM付きのリンク: 319
source / medium / campaign の組(→ メディアから見た既定のチャネル):
  yamayamabloglink / referral / blog_cta → Referral: 319件
自社の別サイトへの、UTMの無いリンク: 1083件(行き先 27種類)
エラー: 0 / 注意: 2

[注意] 値に決めた文字以外がある: 2件
  /【stripe】next-js-15とprismaで実装するサブスクリプション機能/  https://www.bundlyze.co.jp/service/system-development?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=system_%E3%80%90stripe...  (utm_content=system_【stripe】next-js-15とprismaで実装するサブスクリプション機能)
  /【stripe】next-js-15とprismaで実装するサブスクリプション機能/  https://www.bundlyze.co.jp/company?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=system-contact_%E3%80%90stripe...#contact  (utm_content=system-contact_【stripe】next-js-15とprismaで実装するサブスクリプション機能)

(長いURLは途中を省いています。)見つかったものと、その扱いは次のとおりです。

見つかったもの 件数 扱い
utm_contentに日本語と【】が入っている 2件(1記事) 今は残している。 WordPressの時代から日本語のURLのまま引き継いだ記事が1本あり、その記事のスラッグがutm_contentにそのまま入っていた。GA4では日本語のまま記録されるので数え方は崩れないが、レポートで読みにくい。URLは変えられないので、直すならutm_contentだけ別の短い名前を付ける
会社サイトへの、UTMの無いリンク 1,083本(行き先27種類) 数え方を決めてから扱う。 次の節で説明

ほかの項目は0件でした。大文字、3つのパラメータの抜け、一覧に無い source と medium、自分のサイトへのUTM、綴りを間違えた名前、#の後ろのUTM、同じutm_contentの使い回しは、いずれも見つかりませんでした。記事末の案内のリンクは、記事ごとに「サービスのページ」と「問い合わせ」の2本があり、utm_contentの頭をsystem_とsystem-contact_のように分けているため、使い回しにもなっていません。

会社サイトへの、UTMの無いリンクが1,083本あった

UTMが付いているのは記事末の案内だけで、会社サイトへのリンクの大半は、フッターや著者欄などの部品から出ていました。--ownを付けて流すと、行き先ごとの内訳が出ます。

$ node utm-audit.mjs ./dist https://www.yamayamabloglink.com --own
(前半は同じなので省略)
自社の別サイトへの、UTMの無いリンク(行き先ごと):
  www.bundlyze.co.jp/: 720件
  www.bundlyze.co.jp/column/author/tsukayama: 182件
  www.bundlyze.co.jp/service/system-development: 61件
  www.bundlyze.co.jp/service/website: 23件
  www.bundlyze.co.jp/service/web-strategy: 22件
  www.bundlyze.co.jp/service/seo-llmo: 17件
  www.bundlyze.co.jp/column/requirements-definition-for-clients: 12件
  www.bundlyze.co.jp/service/ai-workflow: 9件
  (以下、19種類で各1〜8件)

会社サイトのトップへの720本は、ほぼすべてのページのフッターに2本ずつ出ているもの(269ページで538本)と、記事のページの著者欄に1本ずつ出ているもの(182本)を合わせた数です。著者のページへの182本も著者欄から、サービスのページへのリンクは記事の本文に手で書いたものでした。

これらのリンクから会社サイトに行った人は、UTMが無いので、ブラウザが送るリンク元で数えられます。このブログのレスポンスにはreferrer-policy: strict-origin-when-cross-originが付いていて(curl -Iで確かめました)、別のサイトへはドメインまでのリンク元が送られます。そのため、会社サイトのGA4では、このブログからの訪問が次の2つに分かれて記録されるはずです。

リンク 参照元 メディア
記事末の案内(UTMあり) yamayamabloglink referral
フッター・著者欄・本文(UTMなし) このブログのドメイン referral

どちらも既定のチャネルはReferralなので、チャネル単位では一緒に数えられます。ただ、参照元の表では2行に分かれ、「このブログからの訪問」を見るときに片方を見落とすおそれがあります。手は2つ考えられます。部品のリンクにもUTMを付けて1つの参照元にそろえるか、UTMは記事末の案内だけに残して、会社サイトのGA4の側で2つの参照元をまとめる条件を作るかです。このブログでは、案内が押された数とそれ以外を分けて見たいので、後者を候補にしています。会社サイトのGA4で、実際にこの2行に分かれているかは、まだ確かめていません。

GA4の側で参照元をまとめる条件の書き方と並べ方は、AI検索からの訪問をGA4で数える記事のカスタムチャネルグループの手順が、そのまま使えます。

わざと崩したリンクで、見落とさないかを確かめた

エラーが0件だと、正しく働いた結果なのか、見落としているだけなのかが分かりません。そこで、崩したリンクを9本並べた1ページのHTMLを作って流し、入れたものがすべて出るかを確かめました。

fixture/test/index.htmlhtml
<!doctype html><html lang="ja"><head><title>t</title></head><body>
<a href="/about/?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">1</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=Yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">2</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral">3</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=facebook.com&utm_medium=sns&utm_campaign=blog_cta">4</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_cotent=x">5</a>
<a href="https://www.bundlyze.co.jp/service#utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">6</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=&utm_content=a">7</a>
<a href="https://www.bundlyze.co.jp/service/website?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=a">8</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog%20cta">9</a>
</body></html>
$ node utm-audit.mjs ./fixture https://www.yamayamabloglink.com
点検したページ: 1 / http(s)のリンク: 9 / UTM付きのリンク: 8
(組の一覧は省略)
エラー: 8 / 注意: 3

[エラー] #の後ろにUTMがある: 1件
[エラー] utm_campaign が無い: 1件
[エラー] 一覧に無い utm_medium: 1件
[エラー] 一覧に無い utm_source: 1件
[エラー] 値が空: 1件
[エラー] 値に大文字がある: 1件
[エラー] 知らないパラメータ名(綴り間違い?): 1件
[エラー] 自サイトへのリンクにUTMがある: 1件
[注意] メディアだけでは既定のチャネルが決まらない: 1件
[注意] 値に決めた文字以外がある: 1件
[注意] 同じ utm_content が別の行き先にある: 1件

入れた9本は、すべて何かに引っかかりました。1本目は自分のサイトへのUTM、2本目は大文字、3本目はutm_campaignの抜け、4本目は一覧に無い source と medium、5本目は綴り間違い、6本目は#の後ろ、7本目は空の値、8本目は7本目と同じutm_contentを別の行き先に使ったもの、9本目は空白の入った値です。

4本目のutm_medium=snsは、注意の「メディアだけでは既定のチャネルが決まらない」にも出ています。このリンクは参照元がfacebook.comなので、GA4ではSNSの一覧に当たってOrganic Socialになるはずで、Unassignedにはなりません。mediumだけの早見では、ここまでは判定できないので、エラーではなく注意にとどめています。

最初に流したときは、空のutm_campaignが「値が空」と「utm_campaign が無い」の2つに出て、件数が二重になっていました。値が空のときは「無い」とは数えないように直しています。

ビルドのたびに流すなら、注意の2件を先に片付ける

ビルドのあとに続けて流し、エラーがあれば終了コード1でデプロイを止める使い方ができます。このブログではまだビルドに入れておらず、この記事のために手で流しただけです。

入れる前に、今の注意2件の扱いを決めておく必要があります。日本語のスラッグの記事のutm_contentを短い名前に変えるか、その記事だけ点検の対象から外すかです。注意が残ったままだと、新しく増えた注意が埋もれます。

ビルド後のHTMLを読む点検は、title・説明文・構造化データ・リンク切れなどでも同じ形で組めます。そちらはサイトの全ページをビルド後のHTMLから点検するスクリプトの記事に載せています。GA4の画面のどこで何を見るかは、会社のコラムGA4を見やすくする方法。見る画面を3つに絞り、足りない数字だけ1か所に集めるにまとめました。

株式会社bundlyzeでは、WEB戦略代行として、問い合わせの入り口を数える計測の設定から、毎月の数字の報告と次にやることの提案までを引き受けています。計測の決まりから整えたい場合は、WEB戦略のページに進め方を載せています。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
cheerio 1.2.0
点検した相手 このブログを2026年10月4日23時ごろにビルドしたdistのコピー(Astro 7.3.5。RSSを除く270ページ)
確かめたこと 実際のdistでの結果、崩したリンク9本のページでの検出、二重に数えていた件の修正後の結果、1回にかかる時間(約0.9秒)

会社サイトのGA4で、このブログからの訪問が実際に2つの参照元に分かれているかは確かめていません。ビルドへの組み込み、CIでの実行、JavaScriptで後から足すリンク、メールやSNSの投稿など、HTMLの外で配るURLの点検もしていません。HTMLの外で配るURLは、作るときに同じRULESで確かめる仕組みが別に要ります。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

utm_source=Instagram と utm_source=instagram は、GA4で同じものとして数えられますか?

別の値として数えられます。アナリティクスのヘルプには、パラメータの値は大文字と小文字が区別されると書かれています。一方で、既定のチャネルグループの条件は大文字と小文字を区別しないため、チャネルは同じでも、参照元の表では2行に分かれます。このスクリプトでは、値に大文字があればエラーにしています。

UTMパラメータは、どれが必須ですか?

アナリティクスのヘルプでは、utm_source、utm_medium、utm_campaign の3つを使う必要があるとされ、utm_id と utm_source_platform も付けることを強く勧めています。付けなかったパラメータはレポートで「(not set)」になります。このスクリプトでは、3つのどれかが無ければエラーにし、残りは任意にしています。

自分のサイトの中のリンクに UTM を付けてはいけないのですか?

このブログでは、自社の決まりとして付けないことにしています。UTMは外からどう来たかを表す目印で、GA4のヘルプでも、セッションの経路を決める情報の一つとして扱われています。サイトの中の移動に付けると、外から来た経路と同じ欄に、サイトの中の場所を表す値が入り込みます。サイトの中のどのリンクが押されたかを見たいときは、UTMではなくクリックのイベントで数えます。

このブログからのリンクに UTM が付いていないと、会社サイトのGA4ではどう見えますか?

ブラウザがリンク元を送っていれば、参照元がこのブログのドメイン、メディアが referral の訪問として数えられます。このブログはリンク元をドメインまで送る設定(strict-origin-when-cross-origin)なので、UTMの付いた記事末の案内(参照元 yamayamabloglink)と、UTMの無いフッターなどのリンク(参照元はドメイン)が、別の行に分かれて見えるはずです。会社サイトのGA4での実際の見え方は、この記事では確かめていません。